Run prettier
This commit is contained in:
		
							parent
							
								
									206ea312bf
								
							
						
					
					
						commit
						6f6a2d1f69
					
				
					 49 changed files with 22902 additions and 6633 deletions
				
			
		
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -5,179 +5,219 @@ import IconButton from "@mui/material/IconButton"; | |||
| import MenuIcon from "@mui/icons-material/Menu"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import * as React from "react"; | ||||
| import {useState} from "react"; | ||||
| import { useState } from "react"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import {topicDisplayName} from "../app/utils"; | ||||
| import { topicDisplayName } from "../app/utils"; | ||||
| import db from "../app/db"; | ||||
| import {useLocation, useNavigate} from "react-router-dom"; | ||||
| import MenuItem from '@mui/material/MenuItem'; | ||||
| import { useLocation, useNavigate } from "react-router-dom"; | ||||
| import MenuItem from "@mui/material/MenuItem"; | ||||
| import MoreVertIcon from "@mui/icons-material/MoreVert"; | ||||
| import NotificationsIcon from '@mui/icons-material/Notifications'; | ||||
| import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; | ||||
| import NotificationsIcon from "@mui/icons-material/Notifications"; | ||||
| import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; | ||||
| import routes from "./routes"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import logo from "../img/ntfy.svg"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import session from "../app/Session"; | ||||
| import AccountCircleIcon from '@mui/icons-material/AccountCircle'; | ||||
| import AccountCircleIcon from "@mui/icons-material/AccountCircle"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Divider from "@mui/material/Divider"; | ||||
| import {Logout, Person, Settings} from "@mui/icons-material"; | ||||
| import { Logout, Person, Settings } from "@mui/icons-material"; | ||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
| import PopupMenu from "./PopupMenu"; | ||||
| import { SubscriptionPopup } from "./SubscriptionPopup"; | ||||
| 
 | ||||
| const ActionBar = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const location = useLocation(); | ||||
|     let title = "ntfy"; | ||||
|     if (props.selected) { | ||||
|         title = topicDisplayName(props.selected); | ||||
|     } else if (location.pathname === routes.settings) { | ||||
|         title = t("action_bar_settings"); | ||||
|     } else if (location.pathname === routes.account) { | ||||
|         title = t("action_bar_account"); | ||||
|     } | ||||
|     return ( | ||||
|         <AppBar position="fixed" sx={{ | ||||
|             width: '100%', | ||||
|             zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
 | ||||
|             ml: { sm: `${Navigation.width}px` } | ||||
|         }}> | ||||
|             <Toolbar sx={{ | ||||
|                 pr: '24px', | ||||
|                 background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)" | ||||
|             }}> | ||||
|                 <IconButton | ||||
|                     color="inherit" | ||||
|                     edge="start" | ||||
|                     aria-label={t("action_bar_show_menu")} | ||||
|                     onClick={props.onMobileDrawerToggle} | ||||
|                     sx={{ mr: 2, display: { sm: 'none' } }} | ||||
|                 > | ||||
|                     <MenuIcon /> | ||||
|                 </IconButton> | ||||
|                 <Box | ||||
|                     component="img" | ||||
|                     src={logo} | ||||
|                     alt={t("action_bar_logo_alt")} | ||||
|                     sx={{ | ||||
|                         display: { xs: 'none', sm: 'block' }, | ||||
|                         marginRight: '10px', | ||||
|                         height: '28px' | ||||
|                     }} | ||||
|                 /> | ||||
|                 <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> | ||||
|                     {title} | ||||
|                 </Typography> | ||||
|                 {props.selected && | ||||
|                     <SettingsIcons | ||||
|                         subscription={props.selected} | ||||
|                         onUnsubscribe={props.onUnsubscribe} | ||||
|                     />} | ||||
|                 <ProfileIcon/> | ||||
|             </Toolbar> | ||||
|         </AppBar> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   const location = useLocation(); | ||||
|   let title = "ntfy"; | ||||
|   if (props.selected) { | ||||
|     title = topicDisplayName(props.selected); | ||||
|   } else if (location.pathname === routes.settings) { | ||||
|     title = t("action_bar_settings"); | ||||
|   } else if (location.pathname === routes.account) { | ||||
|     title = t("action_bar_account"); | ||||
|   } | ||||
|   return ( | ||||
|     <AppBar | ||||
|       position="fixed" | ||||
|       sx={{ | ||||
|         width: "100%", | ||||
|         zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
 | ||||
|         ml: { sm: `${Navigation.width}px` }, | ||||
|       }} | ||||
|     > | ||||
|       <Toolbar | ||||
|         sx={{ | ||||
|           pr: "24px", | ||||
|           background: | ||||
|             "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)", | ||||
|         }} | ||||
|       > | ||||
|         <IconButton | ||||
|           color="inherit" | ||||
|           edge="start" | ||||
|           aria-label={t("action_bar_show_menu")} | ||||
|           onClick={props.onMobileDrawerToggle} | ||||
|           sx={{ mr: 2, display: { sm: "none" } }} | ||||
|         > | ||||
|           <MenuIcon /> | ||||
|         </IconButton> | ||||
|         <Box | ||||
|           component="img" | ||||
|           src={logo} | ||||
|           alt={t("action_bar_logo_alt")} | ||||
|           sx={{ | ||||
|             display: { xs: "none", sm: "block" }, | ||||
|             marginRight: "10px", | ||||
|             height: "28px", | ||||
|           }} | ||||
|         /> | ||||
|         <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> | ||||
|           {title} | ||||
|         </Typography> | ||||
|         {props.selected && ( | ||||
|           <SettingsIcons | ||||
|             subscription={props.selected} | ||||
|             onUnsubscribe={props.onUnsubscribe} | ||||
|           /> | ||||
|         )} | ||||
|         <ProfileIcon /> | ||||
|       </Toolbar> | ||||
|     </AppBar> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const SettingsIcons = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [anchorEl, setAnchorEl] = useState(null); | ||||
|     const subscription = props.subscription; | ||||
|   const { t } = useTranslation(); | ||||
|   const [anchorEl, setAnchorEl] = useState(null); | ||||
|   const subscription = props.subscription; | ||||
| 
 | ||||
|     const handleToggleMute = async () => { | ||||
|         const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
 | ||||
|         await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); | ||||
|     } | ||||
|   const handleToggleMute = async () => { | ||||
|     const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
 | ||||
|     await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}> | ||||
|                 {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>} | ||||
|             </IconButton> | ||||
|             <IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> | ||||
|                 <MoreVertIcon/> | ||||
|             </IconButton> | ||||
|             <SubscriptionPopup | ||||
|                 subscription={subscription} | ||||
|                 anchor={anchorEl} | ||||
|                 placement="right" | ||||
|                 onClose={() => setAnchorEl(null)} | ||||
|             /> | ||||
|         </> | ||||
|     ); | ||||
|   return ( | ||||
|     <> | ||||
|       <IconButton | ||||
|         color="inherit" | ||||
|         size="large" | ||||
|         edge="end" | ||||
|         onClick={handleToggleMute} | ||||
|         aria-label={t("action_bar_toggle_mute")} | ||||
|       > | ||||
|         {subscription.mutedUntil ? ( | ||||
|           <NotificationsOffIcon /> | ||||
|         ) : ( | ||||
|           <NotificationsIcon /> | ||||
|         )} | ||||
|       </IconButton> | ||||
|       <IconButton | ||||
|         color="inherit" | ||||
|         size="large" | ||||
|         edge="end" | ||||
|         onClick={(ev) => setAnchorEl(ev.currentTarget)} | ||||
|         aria-label={t("action_bar_toggle_action_menu")} | ||||
|       > | ||||
|         <MoreVertIcon /> | ||||
|       </IconButton> | ||||
|       <SubscriptionPopup | ||||
|         subscription={subscription} | ||||
|         anchor={anchorEl} | ||||
|         placement="right" | ||||
|         onClose={() => setAnchorEl(null)} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const ProfileIcon = () => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [anchorEl, setAnchorEl] = useState(null); | ||||
|     const open = Boolean(anchorEl); | ||||
|     const navigate = useNavigate(); | ||||
|   const { t } = useTranslation(); | ||||
|   const [anchorEl, setAnchorEl] = useState(null); | ||||
|   const open = Boolean(anchorEl); | ||||
|   const navigate = useNavigate(); | ||||
| 
 | ||||
|     const handleClick = (event) => { | ||||
|         setAnchorEl(event.currentTarget); | ||||
|     }; | ||||
|   const handleClick = (event) => { | ||||
|     setAnchorEl(event.currentTarget); | ||||
|   }; | ||||
| 
 | ||||
|     const handleClose = () => { | ||||
|         setAnchorEl(null); | ||||
|     }; | ||||
|   const handleClose = () => { | ||||
|     setAnchorEl(null); | ||||
|   }; | ||||
| 
 | ||||
|     const handleLogout = async () => { | ||||
|         try { | ||||
|             await accountApi.logout(); | ||||
|             await db.delete(); | ||||
|         } finally { | ||||
|             session.resetAndRedirect(routes.app); | ||||
|         } | ||||
|     }; | ||||
|   const handleLogout = async () => { | ||||
|     try { | ||||
|       await accountApi.logout(); | ||||
|       await db.delete(); | ||||
|     } finally { | ||||
|       session.resetAndRedirect(routes.app); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             {session.exists() && | ||||
|                 <IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}> | ||||
|                     <AccountCircleIcon/> | ||||
|                 </IconButton> | ||||
|             } | ||||
|             {!session.exists() && config.enable_login && | ||||
|                 <Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}> | ||||
|                     {t("action_bar_sign_in")} | ||||
|                 </Button> | ||||
|             } | ||||
|             {!session.exists() && config.enable_signup && | ||||
|                 <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}> | ||||
|                     {t("action_bar_sign_up")} | ||||
|                 </Button> | ||||
|             } | ||||
|             <PopupMenu | ||||
|                 horizontal="right" | ||||
|                 anchorEl={anchorEl} | ||||
|                 open={open} | ||||
|                 onClose={handleClose} | ||||
|             > | ||||
|                 <MenuItem onClick={() => navigate(routes.account)}> | ||||
|                     <ListItemIcon> | ||||
|                         <Person /> | ||||
|                     </ListItemIcon> | ||||
|                     <b>{session.username()}</b> | ||||
|                 </MenuItem> | ||||
|                 <Divider /> | ||||
|                 <MenuItem onClick={() => navigate(routes.settings)}> | ||||
|                     <ListItemIcon> | ||||
|                         <Settings fontSize="small" /> | ||||
|                     </ListItemIcon> | ||||
|                     {t("action_bar_profile_settings")} | ||||
|                 </MenuItem> | ||||
|                 <MenuItem onClick={handleLogout}> | ||||
|                     <ListItemIcon> | ||||
|                         <Logout fontSize="small" /> | ||||
|                     </ListItemIcon> | ||||
|                     {t("action_bar_profile_logout")} | ||||
|                 </MenuItem> | ||||
|             </PopupMenu> | ||||
|         </> | ||||
|     ); | ||||
|   return ( | ||||
|     <> | ||||
|       {session.exists() && ( | ||||
|         <IconButton | ||||
|           color="inherit" | ||||
|           size="large" | ||||
|           edge="end" | ||||
|           onClick={handleClick} | ||||
|           aria-label={t("action_bar_profile_title")} | ||||
|         > | ||||
|           <AccountCircleIcon /> | ||||
|         </IconButton> | ||||
|       )} | ||||
|       {!session.exists() && config.enable_login && ( | ||||
|         <Button | ||||
|           color="inherit" | ||||
|           variant="text" | ||||
|           onClick={() => navigate(routes.login)} | ||||
|           sx={{ m: 1 }} | ||||
|           aria-label={t("action_bar_sign_in")} | ||||
|         > | ||||
|           {t("action_bar_sign_in")} | ||||
|         </Button> | ||||
|       )} | ||||
|       {!session.exists() && config.enable_signup && ( | ||||
|         <Button | ||||
|           color="inherit" | ||||
|           variant="outlined" | ||||
|           onClick={() => navigate(routes.signup)} | ||||
|           aria-label={t("action_bar_sign_up")} | ||||
|         > | ||||
|           {t("action_bar_sign_up")} | ||||
|         </Button> | ||||
|       )} | ||||
|       <PopupMenu | ||||
|         horizontal="right" | ||||
|         anchorEl={anchorEl} | ||||
|         open={open} | ||||
|         onClose={handleClose} | ||||
|       > | ||||
|         <MenuItem onClick={() => navigate(routes.account)}> | ||||
|           <ListItemIcon> | ||||
|             <Person /> | ||||
|           </ListItemIcon> | ||||
|           <b>{session.username()}</b> | ||||
|         </MenuItem> | ||||
|         <Divider /> | ||||
|         <MenuItem onClick={() => navigate(routes.settings)}> | ||||
|           <ListItemIcon> | ||||
|             <Settings fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
|           {t("action_bar_profile_settings")} | ||||
|         </MenuItem> | ||||
|         <MenuItem onClick={handleLogout}> | ||||
|           <ListItemIcon> | ||||
|             <Logout fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
|           {t("action_bar_profile_logout")} | ||||
|         </MenuItem> | ||||
|       </PopupMenu> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ActionBar; | ||||
|  |  | |||
|  | @ -1,27 +1,43 @@ | |||
| import * as React from 'react'; | ||||
| import {createContext, Suspense, useContext, useEffect, useState} from 'react'; | ||||
| import Box from '@mui/material/Box'; | ||||
| import {ThemeProvider} from '@mui/material/styles'; | ||||
| import CssBaseline from '@mui/material/CssBaseline'; | ||||
| import Toolbar from '@mui/material/Toolbar'; | ||||
| import {AllSubscriptions, SingleSubscription} from "./Notifications"; | ||||
| import * as React from "react"; | ||||
| import { | ||||
|   createContext, | ||||
|   Suspense, | ||||
|   useContext, | ||||
|   useEffect, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import { ThemeProvider } from "@mui/material/styles"; | ||||
| import CssBaseline from "@mui/material/CssBaseline"; | ||||
| import Toolbar from "@mui/material/Toolbar"; | ||||
| import { AllSubscriptions, SingleSubscription } from "./Notifications"; | ||||
| import theme from "./theme"; | ||||
| import Navigation from "./Navigation"; | ||||
| import ActionBar from "./ActionBar"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import Preferences from "./Preferences"; | ||||
| import {useLiveQuery} from "dexie-react-hooks"; | ||||
| import { useLiveQuery } from "dexie-react-hooks"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import userManager from "../app/UserManager"; | ||||
| import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom"; | ||||
| import {expandUrl} from "../app/utils"; | ||||
| import { | ||||
|   BrowserRouter, | ||||
|   Outlet, | ||||
|   Route, | ||||
|   Routes, | ||||
|   useParams, | ||||
| } from "react-router-dom"; | ||||
| import { expandUrl } from "../app/utils"; | ||||
| import ErrorBoundary from "./ErrorBoundary"; | ||||
| import routes from "./routes"; | ||||
| import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks"; | ||||
| import { | ||||
|   useAccountListener, | ||||
|   useBackgroundProcesses, | ||||
|   useConnectionListeners, | ||||
| } from "./hooks"; | ||||
| import PublishDialog from "./PublishDialog"; | ||||
| import Messaging from "./Messaging"; | ||||
| import "./i18n"; // Translations!
 | ||||
| import {Backdrop, CircularProgress} from "@mui/material"; | ||||
| import { Backdrop, CircularProgress } from "@mui/material"; | ||||
| import Login from "./Login"; | ||||
| import Signup from "./Signup"; | ||||
| import Account from "./Account"; | ||||
|  | @ -29,119 +45,145 @@ import Account from "./Account"; | |||
| export const AccountContext = createContext(null); | ||||
| 
 | ||||
| const App = () => { | ||||
|     const [account, setAccount] = useState(null); | ||||
|     return ( | ||||
|         <Suspense fallback={<Loader />}> | ||||
|             <BrowserRouter> | ||||
|                 <ThemeProvider theme={theme}> | ||||
|                     <AccountContext.Provider value={{ account, setAccount }}> | ||||
|                         <CssBaseline/> | ||||
|                         <ErrorBoundary> | ||||
|                             <Routes> | ||||
|                                 <Route path={routes.login} element={<Login/>}/> | ||||
|                                 <Route path={routes.signup} element={<Signup/>}/> | ||||
|                                 <Route element={<Layout/>}> | ||||
|                                     <Route path={routes.app} element={<AllSubscriptions/>}/> | ||||
|                                     <Route path={routes.account} element={<Account/>}/> | ||||
|                                     <Route path={routes.settings} element={<Preferences/>}/> | ||||
|                                     <Route path={routes.subscription} element={<SingleSubscription/>}/> | ||||
|                                     <Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/> | ||||
|                                 </Route> | ||||
|                             </Routes> | ||||
|                         </ErrorBoundary> | ||||
|                     </AccountContext.Provider> | ||||
|                 </ThemeProvider> | ||||
|             </BrowserRouter> | ||||
|         </Suspense> | ||||
|     ); | ||||
| } | ||||
|   const [account, setAccount] = useState(null); | ||||
|   return ( | ||||
|     <Suspense fallback={<Loader />}> | ||||
|       <BrowserRouter> | ||||
|         <ThemeProvider theme={theme}> | ||||
|           <AccountContext.Provider value={{ account, setAccount }}> | ||||
|             <CssBaseline /> | ||||
|             <ErrorBoundary> | ||||
|               <Routes> | ||||
|                 <Route path={routes.login} element={<Login />} /> | ||||
|                 <Route path={routes.signup} element={<Signup />} /> | ||||
|                 <Route element={<Layout />}> | ||||
|                   <Route path={routes.app} element={<AllSubscriptions />} /> | ||||
|                   <Route path={routes.account} element={<Account />} /> | ||||
|                   <Route path={routes.settings} element={<Preferences />} /> | ||||
|                   <Route | ||||
|                     path={routes.subscription} | ||||
|                     element={<SingleSubscription />} | ||||
|                   /> | ||||
|                   <Route | ||||
|                     path={routes.subscriptionExternal} | ||||
|                     element={<SingleSubscription />} | ||||
|                   /> | ||||
|                 </Route> | ||||
|               </Routes> | ||||
|             </ErrorBoundary> | ||||
|           </AccountContext.Provider> | ||||
|         </ThemeProvider> | ||||
|       </BrowserRouter> | ||||
|     </Suspense> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Layout = () => { | ||||
|     const params = useParams(); | ||||
|     const { account, setAccount } = useContext(AccountContext); | ||||
|     const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); | ||||
|     const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); | ||||
|     const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); | ||||
|     const users = useLiveQuery(() => userManager.all()); | ||||
|     const subscriptions = useLiveQuery(() => subscriptionManager.all()); | ||||
|     const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal); | ||||
|     const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; | ||||
|     const [selected] = (subscriptionsWithoutInternal || []).filter(s => { | ||||
|         return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) | ||||
|             || (config.base_url === s.baseUrl && params.topic === s.topic) | ||||
|     }); | ||||
| 
 | ||||
|     useConnectionListeners(account, subscriptions, users); | ||||
|     useAccountListener(setAccount) | ||||
|     useBackgroundProcesses(); | ||||
|     useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); | ||||
| 
 | ||||
|   const params = useParams(); | ||||
|   const { account, setAccount } = useContext(AccountContext); | ||||
|   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); | ||||
|   const [notificationsGranted, setNotificationsGranted] = useState( | ||||
|     notifier.granted() | ||||
|   ); | ||||
|   const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); | ||||
|   const users = useLiveQuery(() => userManager.all()); | ||||
|   const subscriptions = useLiveQuery(() => subscriptionManager.all()); | ||||
|   const subscriptionsWithoutInternal = subscriptions?.filter( | ||||
|     (s) => !s.internal | ||||
|   ); | ||||
|   const newNotificationsCount = | ||||
|     subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; | ||||
|   const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { | ||||
|     return ( | ||||
|         <Box sx={{display: 'flex'}}> | ||||
|             <ActionBar | ||||
|                 selected={selected} | ||||
|                 onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} | ||||
|             /> | ||||
|             <Navigation | ||||
|                 subscriptions={subscriptionsWithoutInternal} | ||||
|                 selectedSubscription={selected} | ||||
|                 notificationsGranted={notificationsGranted} | ||||
|                 mobileDrawerOpen={mobileDrawerOpen} | ||||
|                 onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} | ||||
|                 onNotificationGranted={setNotificationsGranted} | ||||
|                 onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} | ||||
|             /> | ||||
|             <Main> | ||||
|                 <Toolbar/> | ||||
|                 <Outlet context={{ | ||||
|                     subscriptions: subscriptionsWithoutInternal, | ||||
|                     selected: selected | ||||
|                 }}/> | ||||
|             </Main> | ||||
|             <Messaging | ||||
|                 selected={selected} | ||||
|                 dialogOpenMode={sendDialogOpenMode} | ||||
|                 onDialogOpenModeChange={setSendDialogOpenMode} | ||||
|             /> | ||||
|         </Box> | ||||
|       (params.baseUrl && | ||||
|         expandUrl(params.baseUrl).includes(s.baseUrl) && | ||||
|         params.topic === s.topic) || | ||||
|       (config.base_url === s.baseUrl && params.topic === s.topic) | ||||
|     ); | ||||
| } | ||||
|   }); | ||||
| 
 | ||||
|   useConnectionListeners(account, subscriptions, users); | ||||
|   useAccountListener(setAccount); | ||||
|   useBackgroundProcesses(); | ||||
|   useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); | ||||
| 
 | ||||
|   return ( | ||||
|     <Box sx={{ display: "flex" }}> | ||||
|       <ActionBar | ||||
|         selected={selected} | ||||
|         onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} | ||||
|       /> | ||||
|       <Navigation | ||||
|         subscriptions={subscriptionsWithoutInternal} | ||||
|         selectedSubscription={selected} | ||||
|         notificationsGranted={notificationsGranted} | ||||
|         mobileDrawerOpen={mobileDrawerOpen} | ||||
|         onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} | ||||
|         onNotificationGranted={setNotificationsGranted} | ||||
|         onPublishMessageClick={() => | ||||
|           setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT) | ||||
|         } | ||||
|       /> | ||||
|       <Main> | ||||
|         <Toolbar /> | ||||
|         <Outlet | ||||
|           context={{ | ||||
|             subscriptions: subscriptionsWithoutInternal, | ||||
|             selected: selected, | ||||
|           }} | ||||
|         /> | ||||
|       </Main> | ||||
|       <Messaging | ||||
|         selected={selected} | ||||
|         dialogOpenMode={sendDialogOpenMode} | ||||
|         onDialogOpenModeChange={setSendDialogOpenMode} | ||||
|       /> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Main = (props) => { | ||||
|     return ( | ||||
|         <Box | ||||
|             id="main" | ||||
|             component="main" | ||||
|             sx={{ | ||||
|                 display: 'flex', | ||||
|                 flexGrow: 1, | ||||
|                 flexDirection: 'column', | ||||
|                 padding: 3, | ||||
|                 width: {sm: `calc(100% - ${Navigation.width}px)`}, | ||||
|                 height: '100vh', | ||||
|                 overflow: 'auto', | ||||
|                 backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] | ||||
|             }} | ||||
|         > | ||||
|             {props.children} | ||||
|         </Box> | ||||
|     ); | ||||
|   return ( | ||||
|     <Box | ||||
|       id="main" | ||||
|       component="main" | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         flexGrow: 1, | ||||
|         flexDirection: "column", | ||||
|         padding: 3, | ||||
|         width: { sm: `calc(100% - ${Navigation.width}px)` }, | ||||
|         height: "100vh", | ||||
|         overflow: "auto", | ||||
|         backgroundColor: (theme) => | ||||
|           theme.palette.mode === "light" | ||||
|             ? theme.palette.grey[100] | ||||
|             : theme.palette.grey[900], | ||||
|       }} | ||||
|     > | ||||
|       {props.children} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Loader = () => ( | ||||
|     <Backdrop | ||||
|         open={true} | ||||
|         sx={{ | ||||
|             zIndex: 100000, | ||||
|             backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] | ||||
|         }} | ||||
|     > | ||||
|         <CircularProgress color="success" disableShrink /> | ||||
|     </Backdrop> | ||||
|   <Backdrop | ||||
|     open={true} | ||||
|     sx={{ | ||||
|       zIndex: 100000, | ||||
|       backgroundColor: (theme) => | ||||
|         theme.palette.mode === "light" | ||||
|           ? theme.palette.grey[100] | ||||
|           : theme.palette.grey[900], | ||||
|     }} | ||||
|   > | ||||
|     <CircularProgress color="success" disableShrink /> | ||||
|   </Backdrop> | ||||
| ); | ||||
| 
 | ||||
| const updateTitle = (newNotificationsCount) => { | ||||
|     document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; | ||||
| } | ||||
|   document.title = | ||||
|     newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; | ||||
| }; | ||||
| 
 | ||||
| export default App; | ||||
|  |  | |||
|  | @ -5,43 +5,43 @@ import fileImage from "../img/file-image.svg"; | |||
| import fileVideo from "../img/file-video.svg"; | ||||
| import fileAudio from "../img/file-audio.svg"; | ||||
| import fileApp from "../img/file-app.svg"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| const AttachmentIcon = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const type = props.type; | ||||
|     let imageFile, imageLabel; | ||||
|     if (!type) { | ||||
|         imageFile = fileDocument; | ||||
|         imageLabel = t("notifications_attachment_file_image"); | ||||
|     } else if (type.startsWith('image/')) { | ||||
|         imageFile = fileImage; | ||||
|         imageLabel = t("notifications_attachment_file_video"); | ||||
|     } else if (type.startsWith('video/')) { | ||||
|         imageFile = fileVideo; | ||||
|         imageLabel = t("notifications_attachment_file_video"); | ||||
|     } else if (type.startsWith('audio/')) { | ||||
|         imageFile = fileAudio; | ||||
|         imageLabel = t("notifications_attachment_file_audio"); | ||||
|     } else if (type === "application/vnd.android.package-archive") { | ||||
|         imageFile = fileApp; | ||||
|         imageLabel = t("notifications_attachment_file_app"); | ||||
|     } else { | ||||
|         imageFile = fileDocument; | ||||
|         imageLabel = t("notifications_attachment_file_document"); | ||||
|     } | ||||
|     return ( | ||||
|         <Box | ||||
|             component="img" | ||||
|             src={imageFile} | ||||
|             alt={imageLabel} | ||||
|             loading="lazy" | ||||
|             sx={{ | ||||
|                 width: '28px', | ||||
|                 height: '28px' | ||||
|             }} | ||||
|         /> | ||||
|     ); | ||||
| } | ||||
|   const { t } = useTranslation(); | ||||
|   const type = props.type; | ||||
|   let imageFile, imageLabel; | ||||
|   if (!type) { | ||||
|     imageFile = fileDocument; | ||||
|     imageLabel = t("notifications_attachment_file_image"); | ||||
|   } else if (type.startsWith("image/")) { | ||||
|     imageFile = fileImage; | ||||
|     imageLabel = t("notifications_attachment_file_video"); | ||||
|   } else if (type.startsWith("video/")) { | ||||
|     imageFile = fileVideo; | ||||
|     imageLabel = t("notifications_attachment_file_video"); | ||||
|   } else if (type.startsWith("audio/")) { | ||||
|     imageFile = fileAudio; | ||||
|     imageLabel = t("notifications_attachment_file_audio"); | ||||
|   } else if (type === "application/vnd.android.package-archive") { | ||||
|     imageFile = fileApp; | ||||
|     imageLabel = t("notifications_attachment_file_app"); | ||||
|   } else { | ||||
|     imageFile = fileDocument; | ||||
|     imageLabel = t("notifications_attachment_file_document"); | ||||
|   } | ||||
|   return ( | ||||
|     <Box | ||||
|       component="img" | ||||
|       src={imageFile} | ||||
|       alt={imageLabel} | ||||
|       loading="lazy" | ||||
|       sx={{ | ||||
|         width: "28px", | ||||
|         height: "28px", | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AttachmentIcon; | ||||
|  |  | |||
|  | @ -1,29 +1,29 @@ | |||
| import * as React from 'react'; | ||||
| import {Avatar} from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| import { Avatar } from "@mui/material"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import logo from "../img/ntfy-filled.svg"; | ||||
| 
 | ||||
| const AvatarBox = (props) => { | ||||
|     return ( | ||||
|         <Box | ||||
|             sx={{ | ||||
|                 display: 'flex', | ||||
|                 flexGrow: 1, | ||||
|                 justifyContent: 'center', | ||||
|                 flexDirection: 'column', | ||||
|                 alignContent: 'center', | ||||
|                 alignItems: 'center', | ||||
|                 height: '100vh' | ||||
|             }} | ||||
|         > | ||||
|             <Avatar | ||||
|                 sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} | ||||
|                 src={logo} | ||||
|                 variant="rounded" | ||||
|             /> | ||||
|             {props.children} | ||||
|         </Box> | ||||
|     ); | ||||
| } | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         flexGrow: 1, | ||||
|         justifyContent: "center", | ||||
|         flexDirection: "column", | ||||
|         alignContent: "center", | ||||
|         alignItems: "center", | ||||
|         height: "100vh", | ||||
|       }} | ||||
|     > | ||||
|       <Avatar | ||||
|         sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} | ||||
|         src={logo} | ||||
|         variant="rounded" | ||||
|       /> | ||||
|       {props.children} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AvatarBox; | ||||
|  |  | |||
|  | @ -4,30 +4,30 @@ import DialogContentText from "@mui/material/DialogContentText"; | |||
| import DialogActions from "@mui/material/DialogActions"; | ||||
| 
 | ||||
| const DialogFooter = (props) => { | ||||
|     return ( | ||||
|         <Box sx={{ | ||||
|             display: 'flex', | ||||
|             flexDirection: 'row', | ||||
|             justifyContent: 'space-between', | ||||
|             paddingLeft: '24px', | ||||
|             paddingBottom: '8px', | ||||
|         }}> | ||||
|             <DialogContentText | ||||
|                 component="div" | ||||
|                 aria-live="polite" | ||||
|                 sx={{ | ||||
|                     margin: '0px', | ||||
|                     paddingTop: '12px', | ||||
|                     paddingBottom: '4px' | ||||
|                 }} | ||||
|             > | ||||
|                 {props.status} | ||||
|             </DialogContentText> | ||||
|             <DialogActions sx={{paddingRight: 2}}> | ||||
|                 {props.children} | ||||
|             </DialogActions> | ||||
|         </Box> | ||||
|     ); | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "row", | ||||
|         justifyContent: "space-between", | ||||
|         paddingLeft: "24px", | ||||
|         paddingBottom: "8px", | ||||
|       }} | ||||
|     > | ||||
|       <DialogContentText | ||||
|         component="div" | ||||
|         aria-live="polite" | ||||
|         sx={{ | ||||
|           margin: "0px", | ||||
|           paddingTop: "12px", | ||||
|           paddingBottom: "4px", | ||||
|         }} | ||||
|       > | ||||
|         {props.status} | ||||
|       </DialogContentText> | ||||
|       <DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default DialogFooter; | ||||
|  |  | |||
|  | @ -1,15 +1,15 @@ | |||
| import * as React from 'react'; | ||||
| import {useRef, useState} from 'react'; | ||||
| import Typography from '@mui/material/Typography'; | ||||
| import {rawEmojis} from '../app/emojis'; | ||||
| import * as React from "react"; | ||||
| import { useRef, useState } from "react"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import { rawEmojis } from "../app/emojis"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material"; | ||||
| import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
| import {Close} from "@mui/icons-material"; | ||||
| import { Close } from "@mui/icons-material"; | ||||
| import Popper from "@mui/material/Popper"; | ||||
| import {splitNoEmpty} from "../app/utils"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { splitNoEmpty } from "../app/utils"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| // Create emoji list by category and create a search base (string with all search words)
 | ||||
| //
 | ||||
|  | @ -17,163 +17,185 @@ import {useTranslation} from "react-i18next"; | |||
| // This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
 | ||||
| 
 | ||||
| const emojisByCategory = {}; | ||||
| const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); | ||||
| const isDesktopChrome = | ||||
|   /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); | ||||
| const maxSupportedVersionForDesktopChrome = 11; | ||||
| rawEmojis.forEach(emoji => { | ||||
|     if (!emojisByCategory[emoji.category]) { | ||||
|         emojisByCategory[emoji.category] = []; | ||||
|     } | ||||
|     try { | ||||
|         const unicodeVersion = parseFloat(emoji.unicode_version); | ||||
|         const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; | ||||
|         if (supportedEmoji) { | ||||
|             const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; | ||||
|             const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; | ||||
|             emojisByCategory[emoji.category].push(emojiWithSearchBase); | ||||
|         } | ||||
|     } catch (e) { | ||||
|         // Nothing. Ignore.
 | ||||
| rawEmojis.forEach((emoji) => { | ||||
|   if (!emojisByCategory[emoji.category]) { | ||||
|     emojisByCategory[emoji.category] = []; | ||||
|   } | ||||
|   try { | ||||
|     const unicodeVersion = parseFloat(emoji.unicode_version); | ||||
|     const supportedEmoji = | ||||
|       unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; | ||||
|     if (supportedEmoji) { | ||||
|       const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join( | ||||
|         " " | ||||
|       )} ${emoji.tags.join(" ")}`;
 | ||||
|       const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; | ||||
|       emojisByCategory[emoji.category].push(emojiWithSearchBase); | ||||
|     } | ||||
|   } catch (e) { | ||||
|     // Nothing. Ignore.
 | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| const EmojiPicker = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const open = Boolean(props.anchorEl); | ||||
|     const [search, setSearch] = useState(""); | ||||
|     const searchRef = useRef(null); | ||||
|     const searchFields = splitNoEmpty(search.toLowerCase(), " "); | ||||
|   const { t } = useTranslation(); | ||||
|   const open = Boolean(props.anchorEl); | ||||
|   const [search, setSearch] = useState(""); | ||||
|   const searchRef = useRef(null); | ||||
|   const searchFields = splitNoEmpty(search.toLowerCase(), " "); | ||||
| 
 | ||||
|     const handleSearchClear = () => { | ||||
|         setSearch(""); | ||||
|         searchRef.current?.focus(); | ||||
|     }; | ||||
|   const handleSearchClear = () => { | ||||
|     setSearch(""); | ||||
|     searchRef.current?.focus(); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Popper | ||||
|             open={open} | ||||
|             anchorEl={props.anchorEl} | ||||
|             placement="bottom-start" | ||||
|             sx={{ zIndex: 10005 }} | ||||
|             transition | ||||
|         > | ||||
|             {({ TransitionProps }) => ( | ||||
|                 <ClickAwayListener onClickAway={props.onClose}> | ||||
|                     <Fade {...TransitionProps} timeout={350}> | ||||
|                         <Box sx={{ | ||||
|                             boxShadow: 3, | ||||
|                             padding: 2, | ||||
|                             paddingRight: 0, | ||||
|                             paddingBottom: 1, | ||||
|                             width: "380px", | ||||
|                             maxHeight: "300px", | ||||
|                             backgroundColor: 'background.paper', | ||||
|                             overflowY: "scroll" | ||||
|                         }}> | ||||
|                             <TextField | ||||
|                                 inputRef={searchRef} | ||||
|                                 margin="dense" | ||||
|                                 size="small" | ||||
|                                 placeholder={t("emoji_picker_search_placeholder")} | ||||
|                                 value={search} | ||||
|                                 onChange={ev => setSearch(ev.target.value)} | ||||
|                                 type="text" | ||||
|                                 variant="standard" | ||||
|                                 fullWidth | ||||
|                                 sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} | ||||
|                                 inputProps={{ | ||||
|                                     role: "searchbox", | ||||
|                                     "aria-label": t("emoji_picker_search_placeholder") | ||||
|                                 }} | ||||
|                                 InputProps={{ | ||||
|                                     endAdornment: | ||||
|                                         <InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}> | ||||
|                                             <IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}> | ||||
|                                                 <Close/> | ||||
|                                             </IconButton> | ||||
|                                         </InputAdornment> | ||||
|                                 }} | ||||
|                             /> | ||||
|                             <Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}> | ||||
|                                 {Object.keys(emojisByCategory).map(category => | ||||
|                                     <Category | ||||
|                                         key={category} | ||||
|                                         title={category} | ||||
|                                         emojis={emojisByCategory[category]} | ||||
|                                         search={searchFields} | ||||
|                                         onPick={props.onEmojiPick} | ||||
|                                     /> | ||||
|                                 )} | ||||
|                             </Box> | ||||
|                         </Box> | ||||
|                     </Fade> | ||||
|                 </ClickAwayListener> | ||||
|             )} | ||||
|         </Popper> | ||||
|     ); | ||||
|   return ( | ||||
|     <Popper | ||||
|       open={open} | ||||
|       anchorEl={props.anchorEl} | ||||
|       placement="bottom-start" | ||||
|       sx={{ zIndex: 10005 }} | ||||
|       transition | ||||
|     > | ||||
|       {({ TransitionProps }) => ( | ||||
|         <ClickAwayListener onClickAway={props.onClose}> | ||||
|           <Fade {...TransitionProps} timeout={350}> | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 boxShadow: 3, | ||||
|                 padding: 2, | ||||
|                 paddingRight: 0, | ||||
|                 paddingBottom: 1, | ||||
|                 width: "380px", | ||||
|                 maxHeight: "300px", | ||||
|                 backgroundColor: "background.paper", | ||||
|                 overflowY: "scroll", | ||||
|               }} | ||||
|             > | ||||
|               <TextField | ||||
|                 inputRef={searchRef} | ||||
|                 margin="dense" | ||||
|                 size="small" | ||||
|                 placeholder={t("emoji_picker_search_placeholder")} | ||||
|                 value={search} | ||||
|                 onChange={(ev) => setSearch(ev.target.value)} | ||||
|                 type="text" | ||||
|                 variant="standard" | ||||
|                 fullWidth | ||||
|                 sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} | ||||
|                 inputProps={{ | ||||
|                   role: "searchbox", | ||||
|                   "aria-label": t("emoji_picker_search_placeholder"), | ||||
|                 }} | ||||
|                 InputProps={{ | ||||
|                   endAdornment: ( | ||||
|                     <InputAdornment | ||||
|                       position="end" | ||||
|                       sx={{ display: search ? "" : "none" }} | ||||
|                     > | ||||
|                       <IconButton | ||||
|                         size="small" | ||||
|                         onClick={handleSearchClear} | ||||
|                         edge="end" | ||||
|                         aria-label={t("emoji_picker_search_clear")} | ||||
|                       > | ||||
|                         <Close /> | ||||
|                       </IconButton> | ||||
|                     </InputAdornment> | ||||
|                   ), | ||||
|                 }} | ||||
|               /> | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   display: "flex", | ||||
|                   flexWrap: "wrap", | ||||
|                   paddingRight: 0, | ||||
|                   marginTop: 1, | ||||
|                 }} | ||||
|               > | ||||
|                 {Object.keys(emojisByCategory).map((category) => ( | ||||
|                   <Category | ||||
|                     key={category} | ||||
|                     title={category} | ||||
|                     emojis={emojisByCategory[category]} | ||||
|                     search={searchFields} | ||||
|                     onPick={props.onEmojiPick} | ||||
|                   /> | ||||
|                 ))} | ||||
|               </Box> | ||||
|             </Box> | ||||
|           </Fade> | ||||
|         </ClickAwayListener> | ||||
|       )} | ||||
|     </Popper> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Category = (props) => { | ||||
|     const showTitle = props.search.length === 0; | ||||
|     return ( | ||||
|         <> | ||||
|             {showTitle && | ||||
|                 <Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}> | ||||
|                     {props.title} | ||||
|                 </Typography> | ||||
|             } | ||||
|             {props.emojis.map(emoji => | ||||
|                 <Emoji | ||||
|                     key={emoji.aliases[0]} | ||||
|                     emoji={emoji} | ||||
|                     search={props.search} | ||||
|                     onClick={() => props.onPick(emoji.aliases[0])} | ||||
|                 /> | ||||
|             )} | ||||
|         </> | ||||
|     ); | ||||
|   const showTitle = props.search.length === 0; | ||||
|   return ( | ||||
|     <> | ||||
|       {showTitle && ( | ||||
|         <Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}> | ||||
|           {props.title} | ||||
|         </Typography> | ||||
|       )} | ||||
|       {props.emojis.map((emoji) => ( | ||||
|         <Emoji | ||||
|           key={emoji.aliases[0]} | ||||
|           emoji={emoji} | ||||
|           search={props.search} | ||||
|           onClick={() => props.onPick(emoji.aliases[0])} | ||||
|         /> | ||||
|       ))} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Emoji = (props) => { | ||||
|     const emoji = props.emoji; | ||||
|     const matches = emojiMatches(emoji, props.search); | ||||
|     const title = `${emoji.description} (${emoji.aliases[0]})`; | ||||
|     return ( | ||||
|         <EmojiDiv | ||||
|             onClick={props.onClick} | ||||
|             title={title} | ||||
|             aria-label={title} | ||||
|             style={{ display: (matches) ? '' : 'none' }} | ||||
|         > | ||||
|             {props.emoji.emoji} | ||||
|         </EmojiDiv> | ||||
|     ); | ||||
|   const emoji = props.emoji; | ||||
|   const matches = emojiMatches(emoji, props.search); | ||||
|   const title = `${emoji.description} (${emoji.aliases[0]})`; | ||||
|   return ( | ||||
|     <EmojiDiv | ||||
|       onClick={props.onClick} | ||||
|       title={title} | ||||
|       aria-label={title} | ||||
|       style={{ display: matches ? "" : "none" }} | ||||
|     > | ||||
|       {props.emoji.emoji} | ||||
|     </EmojiDiv> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const EmojiDiv = styled("div")({ | ||||
|     fontSize: "30px", | ||||
|     width: "30px", | ||||
|     height: "30px", | ||||
|     marginTop: "8px", | ||||
|     marginBottom: "8px", | ||||
|     marginRight: "8px", | ||||
|     lineHeight: "30px", | ||||
|     cursor: "pointer", | ||||
|     opacity: 0.85, | ||||
|     "&:hover": { | ||||
|         opacity: 1 | ||||
|     } | ||||
|   fontSize: "30px", | ||||
|   width: "30px", | ||||
|   height: "30px", | ||||
|   marginTop: "8px", | ||||
|   marginBottom: "8px", | ||||
|   marginRight: "8px", | ||||
|   lineHeight: "30px", | ||||
|   cursor: "pointer", | ||||
|   opacity: 0.85, | ||||
|   "&:hover": { | ||||
|     opacity: 1, | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| const emojiMatches = (emoji, words) => { | ||||
|     if (words.length === 0) { | ||||
|         return true; | ||||
|     } | ||||
|     for (const word of words) { | ||||
|         if (emoji.searchBase.indexOf(word) === -1) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|   if (words.length === 0) { | ||||
|     return true; | ||||
| } | ||||
|   } | ||||
|   for (const word of words) { | ||||
|     if (emoji.searchBase.indexOf(word) === -1) { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|   return true; | ||||
| }; | ||||
| 
 | ||||
| export default EmojiPicker; | ||||
|  |  | |||
|  | @ -1,128 +1,151 @@ | |||
| import * as React from "react"; | ||||
| import StackTrace from "stacktrace-js"; | ||||
| import {CircularProgress, Link} from "@mui/material"; | ||||
| import { CircularProgress, Link } from "@mui/material"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import {Trans, withTranslation} from "react-i18next"; | ||||
| import { Trans, withTranslation } from "react-i18next"; | ||||
| 
 | ||||
| class ErrorBoundaryImpl extends React.Component { | ||||
|     constructor(props) { | ||||
|         super(props); | ||||
|         this.state = { | ||||
|             error: false, | ||||
|             originalStack: null, | ||||
|             niceStack: null, | ||||
|             unsupportedIndexedDB: false | ||||
|         }; | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.state = { | ||||
|       error: false, | ||||
|       originalStack: null, | ||||
|       niceStack: null, | ||||
|       unsupportedIndexedDB: false, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   componentDidCatch(error, info) { | ||||
|     console.error("[ErrorBoundary] Error caught", error, info); | ||||
| 
 | ||||
|     // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
 | ||||
|     // - https://github.com/dexie/Dexie.js/issues/312
 | ||||
|     // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
 | ||||
|     const isUnsupportedIndexedDB = | ||||
|       error?.name === "InvalidStateError" || | ||||
|       (error?.name === "DatabaseClosedError" && | ||||
|         error?.message?.indexOf("InvalidStateError") !== -1); | ||||
| 
 | ||||
|     if (isUnsupportedIndexedDB) { | ||||
|       this.handleUnsupportedIndexedDB(); | ||||
|     } else { | ||||
|       this.handleError(error, info); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|     componentDidCatch(error, info) { | ||||
|         console.error("[ErrorBoundary] Error caught", error, info); | ||||
|   handleError(error, info) { | ||||
|     // Immediately render original stack trace
 | ||||
|     const prettierOriginalStack = info.componentStack | ||||
|       .trim() | ||||
|       .split("\n") | ||||
|       .map((line) => `  at ${line}`) | ||||
|       .join("\n"); | ||||
|     this.setState({ | ||||
|       error: true, | ||||
|       originalStack: `${error.toString()}\n${prettierOriginalStack}`, | ||||
|     }); | ||||
| 
 | ||||
|         // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
 | ||||
|         // - https://github.com/dexie/Dexie.js/issues/312
 | ||||
|         // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
 | ||||
|         const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || | ||||
|             (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); | ||||
|     // Fetch additional info and a better stack trace
 | ||||
|     StackTrace.fromError(error).then((stack) => { | ||||
|       console.error("[ErrorBoundary] Stacktrace fetched", stack); | ||||
|       const niceStack = | ||||
|         `${error.toString()}\n` + | ||||
|         stack | ||||
|           .map( | ||||
|             (el) => | ||||
|               `  at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})` | ||||
|           ) | ||||
|           .join("\n"); | ||||
|       this.setState({ niceStack }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|         if (isUnsupportedIndexedDB) { | ||||
|             this.handleUnsupportedIndexedDB(); | ||||
|         } else { | ||||
|             this.handleError(error, info); | ||||
|         } | ||||
|   handleUnsupportedIndexedDB() { | ||||
|     this.setState({ | ||||
|       error: true, | ||||
|       unsupportedIndexedDB: true, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   copyStack() { | ||||
|     let stack = ""; | ||||
|     if (this.state.niceStack) { | ||||
|       stack += `${this.state.niceStack}\n\n`; | ||||
|     } | ||||
|     stack += `${this.state.originalStack}\n`; | ||||
|     navigator.clipboard.writeText(stack); | ||||
|   } | ||||
| 
 | ||||
|     handleError(error, info) { | ||||
|         // Immediately render original stack trace
 | ||||
|         const prettierOriginalStack = info.componentStack | ||||
|             .trim() | ||||
|             .split("\n") | ||||
|             .map(line => `  at ${line}`) | ||||
|             .join("\n"); | ||||
|         this.setState({ | ||||
|             error: true, | ||||
|             originalStack: `${error.toString()}\n${prettierOriginalStack}` | ||||
|         }); | ||||
| 
 | ||||
|         // Fetch additional info and a better stack trace
 | ||||
|         StackTrace.fromError(error).then(stack => { | ||||
|             console.error("[ErrorBoundary] Stacktrace fetched", stack); | ||||
|             const niceStack = `${error.toString()}\n` + stack.map( el => `  at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); | ||||
|             this.setState({ niceStack }); | ||||
|         }); | ||||
|   render() { | ||||
|     if (this.state.error) { | ||||
|       if (this.state.unsupportedIndexedDB) { | ||||
|         return this.renderUnsupportedIndexedDB(); | ||||
|       } else { | ||||
|         return this.renderError(); | ||||
|       } | ||||
|     } | ||||
|     return this.props.children; | ||||
|   } | ||||
| 
 | ||||
|     handleUnsupportedIndexedDB() { | ||||
|         this.setState({ | ||||
|             error: true, | ||||
|             unsupportedIndexedDB: true | ||||
|         }); | ||||
|     } | ||||
|   renderUnsupportedIndexedDB() { | ||||
|     const { t } = this.props; | ||||
|     return ( | ||||
|       <div style={{ margin: "20px" }}> | ||||
|         <h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2> | ||||
|         <p style={{ maxWidth: "600px" }}> | ||||
|           <Trans | ||||
|             i18nKey="error_boundary_unsupported_indexeddb_description" | ||||
|             components={{ | ||||
|               githubLink: ( | ||||
|                 <Link href="https://github.com/binwiederhier/ntfy/issues/208" /> | ||||
|               ), | ||||
|               discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />, | ||||
|               matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />, | ||||
|             }} | ||||
|           /> | ||||
|         </p> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|     copyStack() { | ||||
|         let stack = ""; | ||||
|         if (this.state.niceStack) { | ||||
|             stack += `${this.state.niceStack}\n\n`; | ||||
|         } | ||||
|         stack += `${this.state.originalStack}\n`; | ||||
|         navigator.clipboard.writeText(stack); | ||||
|     } | ||||
| 
 | ||||
|     render() { | ||||
|         if (this.state.error) { | ||||
|             if (this.state.unsupportedIndexedDB) { | ||||
|                 return this.renderUnsupportedIndexedDB(); | ||||
|             } else { | ||||
|                 return this.renderError(); | ||||
|             } | ||||
|         } | ||||
|         return this.props.children; | ||||
|     } | ||||
| 
 | ||||
|     renderUnsupportedIndexedDB() { | ||||
|         const { t } = this.props; | ||||
|         return ( | ||||
|             <div style={{margin: '20px'}}> | ||||
|                 <h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2> | ||||
|                 <p style={{maxWidth: "600px"}}> | ||||
|                     <Trans | ||||
|                         i18nKey="error_boundary_unsupported_indexeddb_description" | ||||
|                         components={{ | ||||
|                             githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>, | ||||
|                             discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>, | ||||
|                             matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/> | ||||
|                         }} | ||||
|                     /> | ||||
|                 </p> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     renderError() { | ||||
|         const { t } = this.props; | ||||
|         return ( | ||||
|             <div style={{margin: '20px'}}> | ||||
|                 <h2>{t("error_boundary_title")} 😮</h2> | ||||
|                 <p> | ||||
|                     <Trans | ||||
|                         i18nKey="error_boundary_description" | ||||
|                         components={{ | ||||
|                             githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>, | ||||
|                             discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>, | ||||
|                             matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/> | ||||
|                         }} | ||||
|                     /> | ||||
|                 </p> | ||||
|                 <p> | ||||
|                     <Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button> | ||||
|                 </p> | ||||
|                 <h3>{t("error_boundary_stack_trace")}</h3> | ||||
|                 {this.state.niceStack | ||||
|                     ? <pre>{this.state.niceStack}</pre> | ||||
|                     : <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>} | ||||
|                 <pre>{this.state.originalStack}</pre> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
|   renderError() { | ||||
|     const { t } = this.props; | ||||
|     return ( | ||||
|       <div style={{ margin: "20px" }}> | ||||
|         <h2>{t("error_boundary_title")} 😮</h2> | ||||
|         <p> | ||||
|           <Trans | ||||
|             i18nKey="error_boundary_description" | ||||
|             components={{ | ||||
|               githubLink: ( | ||||
|                 <Link href="https://github.com/binwiederhier/ntfy/issues" /> | ||||
|               ), | ||||
|               discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />, | ||||
|               matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />, | ||||
|             }} | ||||
|           /> | ||||
|         </p> | ||||
|         <p> | ||||
|           <Button variant="outlined" onClick={() => this.copyStack()}> | ||||
|             {t("error_boundary_button_copy_stack_trace")} | ||||
|           </Button> | ||||
|         </p> | ||||
|         <h3>{t("error_boundary_stack_trace")}</h3> | ||||
|         {this.state.niceStack ? ( | ||||
|           <pre>{this.state.niceStack}</pre> | ||||
|         ) : ( | ||||
|           <> | ||||
|             <CircularProgress | ||||
|               size="20px" | ||||
|               sx={{ verticalAlign: "text-bottom" }} | ||||
|             />{" "} | ||||
|             {t("error_boundary_gathering_info")} | ||||
|           </> | ||||
|         )} | ||||
|         <pre>{this.state.originalStack}</pre> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
 | ||||
|  |  | |||
|  | @ -1,122 +1,135 @@ | |||
| import * as React from 'react'; | ||||
| import {useState} from 'react'; | ||||
| import * as React from "react"; | ||||
| import { useState } from "react"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import WarningAmberIcon from '@mui/icons-material/WarningAmber'; | ||||
| import WarningAmberIcon from "@mui/icons-material/WarningAmber"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import routes from "./routes"; | ||||
| import session from "../app/Session"; | ||||
| import {NavLink} from "react-router-dom"; | ||||
| import { NavLink } from "react-router-dom"; | ||||
| import AvatarBox from "./AvatarBox"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
| import {InputAdornment} from "@mui/material"; | ||||
| import {Visibility, VisibilityOff} from "@mui/icons-material"; | ||||
| import {UnauthorizedError} from "../app/errors"; | ||||
| import { InputAdornment } from "@mui/material"; | ||||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| 
 | ||||
| const Login = () => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [username, setUsername] = useState(""); | ||||
|     const [password, setPassword] = useState(""); | ||||
|     const [showPassword, setShowPassword] = useState(false); | ||||
|   const { t } = useTranslation(); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [showPassword, setShowPassword] = useState(false); | ||||
| 
 | ||||
|     const handleSubmit = async (event) => { | ||||
|         event.preventDefault(); | ||||
|         const user = { username, password }; | ||||
|         try { | ||||
|             const token = await accountApi.login(user); | ||||
|             console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); | ||||
|             session.store(user.username, token); | ||||
|             window.location.href = routes.app; | ||||
|         } catch (e) { | ||||
|             console.log(`[Login] User auth for user ${user.username} failed`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 setError(t("Login failed: Invalid username or password")); | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|     if (!config.enable_login) { | ||||
|         return ( | ||||
|             <AvatarBox> | ||||
|                 <Typography sx={{ typography: 'h6' }}>{t("login_disabled")}</Typography> | ||||
|             </AvatarBox> | ||||
|         ); | ||||
|   const handleSubmit = async (event) => { | ||||
|     event.preventDefault(); | ||||
|     const user = { username, password }; | ||||
|     try { | ||||
|       const token = await accountApi.login(user); | ||||
|       console.log( | ||||
|         `[Login] User auth for user ${user.username} successful, token is ${token}` | ||||
|       ); | ||||
|       session.store(user.username, token); | ||||
|       window.location.href = routes.app; | ||||
|     } catch (e) { | ||||
|       console.log(`[Login] User auth for user ${user.username} failed`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         setError(t("Login failed: Invalid username or password")); | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|   if (!config.enable_login) { | ||||
|     return ( | ||||
|         <AvatarBox> | ||||
|             <Typography sx={{ typography: 'h6' }}> | ||||
|                 {t("login_title")} | ||||
|             </Typography> | ||||
|             <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     id="username" | ||||
|                     label={t("signup_form_username")} | ||||
|                     name="username" | ||||
|                     value={username} | ||||
|                     onChange={ev => setUsername(ev.target.value.trim())} | ||||
|                     autoFocus | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="password" | ||||
|                     label={t("signup_form_password")} | ||||
|                     type={showPassword ? "text" : "password"} | ||||
|                     id="password" | ||||
|                     value={password} | ||||
|                     onChange={ev => setPassword(ev.target.value.trim())} | ||||
|                     autoComplete="current-password" | ||||
|                     InputProps={{ | ||||
|                         endAdornment: ( | ||||
|                             <InputAdornment position="end"> | ||||
|                                 <IconButton | ||||
|                                     aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                                     onClick={() => setShowPassword(!showPassword)} | ||||
|                                     onMouseDown={(ev) => ev.preventDefault()} | ||||
|                                     edge="end" | ||||
|                                 > | ||||
|                                     {showPassword ? <VisibilityOff /> : <Visibility />} | ||||
|                                 </IconButton> | ||||
|                             </InputAdornment> | ||||
|                         ) | ||||
|                     }} | ||||
|                 /> | ||||
|                 <Button | ||||
|                     type="submit" | ||||
|                     fullWidth | ||||
|                     variant="contained" | ||||
|                     disabled={username === "" || password === ""} | ||||
|                     sx={{mt: 2, mb: 2}} | ||||
|                 > | ||||
|                     {t("login_form_button_submit")} | ||||
|                 </Button> | ||||
|                 {error && | ||||
|                     <Box sx={{ | ||||
|                         mb: 1, | ||||
|                         display: 'flex', | ||||
|                         flexGrow: 1, | ||||
|                         justifyContent: 'center', | ||||
|                     }}> | ||||
|                         <WarningAmberIcon color="error" sx={{mr: 1}}/> | ||||
|                         <Typography sx={{color: 'error.main'}}>{error}</Typography> | ||||
|                     </Box> | ||||
|                 } | ||||
|                 <Box sx={{width: "100%"}}> | ||||
|                     {/* This is where the password reset link would go */} | ||||
|                     {config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>} | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </AvatarBox> | ||||
|       <AvatarBox> | ||||
|         <Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography> | ||||
|       </AvatarBox> | ||||
|     ); | ||||
| } | ||||
|   } | ||||
|   return ( | ||||
|     <AvatarBox> | ||||
|       <Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography> | ||||
|       <Box | ||||
|         component="form" | ||||
|         onSubmit={handleSubmit} | ||||
|         noValidate | ||||
|         sx={{ mt: 1, maxWidth: 400 }} | ||||
|       > | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           required | ||||
|           fullWidth | ||||
|           id="username" | ||||
|           label={t("signup_form_username")} | ||||
|           name="username" | ||||
|           value={username} | ||||
|           onChange={(ev) => setUsername(ev.target.value.trim())} | ||||
|           autoFocus | ||||
|         /> | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           required | ||||
|           fullWidth | ||||
|           name="password" | ||||
|           label={t("signup_form_password")} | ||||
|           type={showPassword ? "text" : "password"} | ||||
|           id="password" | ||||
|           value={password} | ||||
|           onChange={(ev) => setPassword(ev.target.value.trim())} | ||||
|           autoComplete="current-password" | ||||
|           InputProps={{ | ||||
|             endAdornment: ( | ||||
|               <InputAdornment position="end"> | ||||
|                 <IconButton | ||||
|                   aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                   onClick={() => setShowPassword(!showPassword)} | ||||
|                   onMouseDown={(ev) => ev.preventDefault()} | ||||
|                   edge="end" | ||||
|                 > | ||||
|                   {showPassword ? <VisibilityOff /> : <Visibility />} | ||||
|                 </IconButton> | ||||
|               </InputAdornment> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|         <Button | ||||
|           type="submit" | ||||
|           fullWidth | ||||
|           variant="contained" | ||||
|           disabled={username === "" || password === ""} | ||||
|           sx={{ mt: 2, mb: 2 }} | ||||
|         > | ||||
|           {t("login_form_button_submit")} | ||||
|         </Button> | ||||
|         {error && ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               mb: 1, | ||||
|               display: "flex", | ||||
|               flexGrow: 1, | ||||
|               justifyContent: "center", | ||||
|             }} | ||||
|           > | ||||
|             <WarningAmberIcon color="error" sx={{ mr: 1 }} /> | ||||
|             <Typography sx={{ color: "error.main" }}>{error}</Typography> | ||||
|           </Box> | ||||
|         )} | ||||
|         <Box sx={{ width: "100%" }}> | ||||
|           {/* This is where the password reset link would go */} | ||||
|           {config.enable_signup && ( | ||||
|             <div style={{ float: "right" }}> | ||||
|               <NavLink to={routes.signup} variant="body1"> | ||||
|                 {t("login_link_signup")} | ||||
|               </NavLink> | ||||
|             </div> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </AvatarBox> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Login; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import * as React from 'react'; | ||||
| import {useState} from 'react'; | ||||
| import * as React from "react"; | ||||
| import { useState } from "react"; | ||||
| import Navigation from "./Navigation"; | ||||
| import Paper from "@mui/material/Paper"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
|  | @ -7,108 +7,135 @@ import TextField from "@mui/material/TextField"; | |||
| import SendIcon from "@mui/icons-material/Send"; | ||||
| import api from "../app/Api"; | ||||
| import PublishDialog from "./PublishDialog"; | ||||
| import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; | ||||
| import {Portal, Snackbar} from "@mui/material"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | ||||
| import { Portal, Snackbar } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| const Messaging = (props) => { | ||||
|     const [message, setMessage] = useState(""); | ||||
|     const [dialogKey, setDialogKey] = useState(0); | ||||
|   const [message, setMessage] = useState(""); | ||||
|   const [dialogKey, setDialogKey] = useState(0); | ||||
| 
 | ||||
|     const dialogOpenMode = props.dialogOpenMode; | ||||
|     const subscription = props.selected; | ||||
|   const dialogOpenMode = props.dialogOpenMode; | ||||
|   const subscription = props.selected; | ||||
| 
 | ||||
|     const handleOpenDialogClick = () => { | ||||
|         props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); | ||||
|     }; | ||||
|   const handleOpenDialogClick = () => { | ||||
|     props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); | ||||
|   }; | ||||
| 
 | ||||
|     const handleDialogClose = () => { | ||||
|         props.onDialogOpenModeChange(""); | ||||
|         setDialogKey(prev => prev+1); | ||||
|     }; | ||||
|   const handleDialogClose = () => { | ||||
|     props.onDialogOpenModeChange(""); | ||||
|     setDialogKey((prev) => prev + 1); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             {subscription && <MessageBar | ||||
|                 subscription={subscription} | ||||
|                 message={message} | ||||
|                 onMessageChange={setMessage} | ||||
|                 onOpenDialogClick={handleOpenDialogClick} | ||||
|             />} | ||||
|             <PublishDialog | ||||
|                 key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
 | ||||
|                 openMode={dialogOpenMode} | ||||
|                 baseUrl={subscription?.baseUrl ?? config.base_url} | ||||
|                 topic={subscription?.topic ?? ""} | ||||
|                 message={message} | ||||
|                 onClose={handleDialogClose} | ||||
|                 onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
 | ||||
|                 onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} | ||||
|             /> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|   return ( | ||||
|     <> | ||||
|       {subscription && ( | ||||
|         <MessageBar | ||||
|           subscription={subscription} | ||||
|           message={message} | ||||
|           onMessageChange={setMessage} | ||||
|           onOpenDialogClick={handleOpenDialogClick} | ||||
|         /> | ||||
|       )} | ||||
|       <PublishDialog | ||||
|         key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
 | ||||
|         openMode={dialogOpenMode} | ||||
|         baseUrl={subscription?.baseUrl ?? config.base_url} | ||||
|         topic={subscription?.topic ?? ""} | ||||
|         message={message} | ||||
|         onClose={handleDialogClose} | ||||
|         onDragEnter={() => | ||||
|           props.onDialogOpenModeChange((prev) => | ||||
|             prev ? prev : PublishDialog.OPEN_MODE_DRAG | ||||
|           ) | ||||
|         } // Only update if not already open
 | ||||
|         onResetOpenMode={() => | ||||
|           props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT) | ||||
|         } | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const MessageBar = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const subscription = props.subscription; | ||||
|     const [snackOpen, setSnackOpen] = useState(false); | ||||
|     const handleSendClick = async () => { | ||||
|         try { | ||||
|             await api.publish(subscription.baseUrl, subscription.topic, props.message); | ||||
|         } catch (e) { | ||||
|             console.log(`[MessageBar] Error publishing message`, e); | ||||
|             setSnackOpen(true); | ||||
|         } | ||||
|         props.onMessageChange(""); | ||||
|     }; | ||||
|     return ( | ||||
|         <Paper | ||||
|             elevation={3} | ||||
|             sx={{ | ||||
|                 display: "flex", | ||||
|                 position: 'fixed', | ||||
|                 bottom: 0, | ||||
|                 right: 0, | ||||
|                 padding: 2, | ||||
|                 width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, | ||||
|                 backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] | ||||
|             }} | ||||
|         > | ||||
|             <IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}> | ||||
|                 <KeyboardArrowUpIcon/> | ||||
|             </IconButton> | ||||
|             <TextField | ||||
|                 autoFocus | ||||
|                 margin="dense" | ||||
|                 placeholder={t("message_bar_type_message")} | ||||
|                 aria-label={t("message_bar_type_message")} | ||||
|                 role="textbox" | ||||
|                 type="text" | ||||
|                 fullWidth | ||||
|                 variant="standard" | ||||
|                 value={props.message} | ||||
|                 onChange={ev => props.onMessageChange(ev.target.value)} | ||||
|                 onKeyPress={(ev) => { | ||||
|                     if (ev.key === 'Enter') { | ||||
|                         ev.preventDefault(); | ||||
|                         handleSendClick(); | ||||
|                     } | ||||
|                 }} | ||||
|             /> | ||||
|             <IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}> | ||||
|                 <SendIcon/> | ||||
|             </IconButton> | ||||
|             <Portal> | ||||
|                 <Snackbar | ||||
|                     open={snackOpen} | ||||
|                     autoHideDuration={3000} | ||||
|                     onClose={() => setSnackOpen(false)} | ||||
|                     message={t("message_bar_error_publishing")} | ||||
|                 /> | ||||
|             </Portal> | ||||
|         </Paper> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   const subscription = props.subscription; | ||||
|   const [snackOpen, setSnackOpen] = useState(false); | ||||
|   const handleSendClick = async () => { | ||||
|     try { | ||||
|       await api.publish( | ||||
|         subscription.baseUrl, | ||||
|         subscription.topic, | ||||
|         props.message | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.log(`[MessageBar] Error publishing message`, e); | ||||
|       setSnackOpen(true); | ||||
|     } | ||||
|     props.onMessageChange(""); | ||||
|   }; | ||||
|   return ( | ||||
|     <Paper | ||||
|       elevation={3} | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         position: "fixed", | ||||
|         bottom: 0, | ||||
|         right: 0, | ||||
|         padding: 2, | ||||
|         width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, | ||||
|         backgroundColor: (theme) => | ||||
|           theme.palette.mode === "light" | ||||
|             ? theme.palette.grey[100] | ||||
|             : theme.palette.grey[900], | ||||
|       }} | ||||
|     > | ||||
|       <IconButton | ||||
|         color="inherit" | ||||
|         size="large" | ||||
|         edge="start" | ||||
|         onClick={props.onOpenDialogClick} | ||||
|         aria-label={t("message_bar_show_dialog")} | ||||
|       > | ||||
|         <KeyboardArrowUpIcon /> | ||||
|       </IconButton> | ||||
|       <TextField | ||||
|         autoFocus | ||||
|         margin="dense" | ||||
|         placeholder={t("message_bar_type_message")} | ||||
|         aria-label={t("message_bar_type_message")} | ||||
|         role="textbox" | ||||
|         type="text" | ||||
|         fullWidth | ||||
|         variant="standard" | ||||
|         value={props.message} | ||||
|         onChange={(ev) => props.onMessageChange(ev.target.value)} | ||||
|         onKeyPress={(ev) => { | ||||
|           if (ev.key === "Enter") { | ||||
|             ev.preventDefault(); | ||||
|             handleSendClick(); | ||||
|           } | ||||
|         }} | ||||
|       /> | ||||
|       <IconButton | ||||
|         color="inherit" | ||||
|         size="large" | ||||
|         edge="end" | ||||
|         onClick={handleSendClick} | ||||
|         aria-label={t("message_bar_publish")} | ||||
|       > | ||||
|         <SendIcon /> | ||||
|       </IconButton> | ||||
|       <Portal> | ||||
|         <Snackbar | ||||
|           open={snackOpen} | ||||
|           autoHideDuration={3000} | ||||
|           onClose={() => setSnackOpen(false)} | ||||
|           message={t("message_bar_error_publishing")} | ||||
|         /> | ||||
|       </Portal> | ||||
|     </Paper> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Messaging; | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import Drawer from "@mui/material/Drawer"; | ||||
| import * as React from "react"; | ||||
| import {useContext, useState} from "react"; | ||||
| import { useContext, useState } from "react"; | ||||
| import ListItemButton from "@mui/material/ListItemButton"; | ||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | ||||
| import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; | ||||
|  | @ -12,360 +12,485 @@ import List from "@mui/material/List"; | |||
| import SettingsIcon from "@mui/icons-material/Settings"; | ||||
| import AddIcon from "@mui/icons-material/Add"; | ||||
| import SubscribeDialog from "./SubscribeDialog"; | ||||
| import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip} from "@mui/material"; | ||||
| import { | ||||
|   Alert, | ||||
|   AlertTitle, | ||||
|   Badge, | ||||
|   CircularProgress, | ||||
|   Link, | ||||
|   ListSubheader, | ||||
|   Portal, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; | ||||
| import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; | ||||
| import routes from "./routes"; | ||||
| import {ConnectionState} from "../app/Connection"; | ||||
| import {useLocation, useNavigate} from "react-router-dom"; | ||||
| import { ConnectionState } from "../app/Connection"; | ||||
| import { useLocation, useNavigate } from "react-router-dom"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material"; | ||||
| import { | ||||
|   ChatBubble, | ||||
|   MoreVert, | ||||
|   NotificationsOffOutlined, | ||||
|   Send, | ||||
| } from "@mui/icons-material"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import config from "../app/config"; | ||||
| import ArticleIcon from '@mui/icons-material/Article'; | ||||
| import {Trans, useTranslation} from "react-i18next"; | ||||
| import ArticleIcon from "@mui/icons-material/Article"; | ||||
| import { Trans, useTranslation } from "react-i18next"; | ||||
| import session from "../app/Session"; | ||||
| import accountApi, {Permission, Role} from "../app/AccountApi"; | ||||
| import CelebrationIcon from '@mui/icons-material/Celebration'; | ||||
| import accountApi, { Permission, Role } from "../app/AccountApi"; | ||||
| import CelebrationIcon from "@mui/icons-material/Celebration"; | ||||
| import UpgradeDialog from "./UpgradeDialog"; | ||||
| import {AccountContext} from "./App"; | ||||
| import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; | ||||
| import { AccountContext } from "./App"; | ||||
| import { | ||||
|   PermissionDenyAll, | ||||
|   PermissionRead, | ||||
|   PermissionReadWrite, | ||||
|   PermissionWrite, | ||||
| } from "./ReserveIcons"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
| 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> | ||||
|     ); | ||||
|   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 { 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 handleSubscribeReset = () => { | ||||
|     setSubscribeDialogOpen(false); | ||||
|     setSubscribeDialogKey((prev) => prev + 1); | ||||
|   }; | ||||
| 
 | ||||
|     const handleSubscribeSubmit = (subscription) => { | ||||
|         console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); | ||||
|         handleSubscribeReset(); | ||||
|         navigate(routes.forSubscription(subscription)); | ||||
|         handleRequestNotificationPermission(); | ||||
|     } | ||||
| 
 | ||||
|     const handleRequestNotificationPermission = () => { | ||||
|        notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted)) | ||||
|     }; | ||||
| 
 | ||||
|     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 showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); | ||||
|     const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
 | ||||
|     const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; | ||||
|     const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : ''; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/> | ||||
|             <List component="nav" sx={{ paddingTop: navListPadding }}> | ||||
|                 {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>} | ||||
|                 {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>} | ||||
|                 {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} | ||||
|                 {!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 handleSubscribeSubmit = (subscription) => { | ||||
|     console.log( | ||||
|       `[Navigation] New subscription: ${subscription.id}`, | ||||
|       subscription | ||||
|     ); | ||||
|     handleSubscribeReset(); | ||||
|     navigate(routes.forSubscription(subscription)); | ||||
|     handleRequestNotificationPermission(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleRequestNotificationPermission = () => { | ||||
|     notifier.maybeRequestPermission((granted) => | ||||
|       props.onNotificationGranted(granted) | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   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 showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); | ||||
|   const showNotificationContextNotSupportedBox = | ||||
|     notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
 | ||||
|   const showNotificationGrantBox = | ||||
|     notifier.supported() && | ||||
|     props.subscriptions?.length > 0 && | ||||
|     !props.notificationsGranted; | ||||
|   const navListPadding = | ||||
|     showNotificationGrantBox || | ||||
|     showNotificationBrowserNotSupportedBox || | ||||
|     showNotificationContextNotSupportedBox | ||||
|       ? "0" | ||||
|       : ""; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Toolbar sx={{ display: { xs: "none", sm: "block" } }} /> | ||||
|       <List component="nav" sx={{ paddingTop: navListPadding }}> | ||||
|         {showNotificationBrowserNotSupportedBox && ( | ||||
|           <NotificationBrowserNotSupportedAlert /> | ||||
|         )} | ||||
|         {showNotificationContextNotSupportedBox && ( | ||||
|           <NotificationContextNotSupportedAlert /> | ||||
|         )} | ||||
|         {showNotificationGrantBox && ( | ||||
|           <NotificationGrantAlert | ||||
|             onRequestPermissionClick={handleRequestNotificationPermission} | ||||
|           /> | ||||
|         )} | ||||
|         {!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 { t } = useTranslation(); | ||||
|   const [dialogKey, setDialogKey] = useState(0); | ||||
|   const [dialogOpen, setDialogOpen] = useState(false); | ||||
| 
 | ||||
|     const handleClick = () => { | ||||
|         setDialogKey(k => k + 1); | ||||
|         setDialogOpen(true); | ||||
|     }; | ||||
|   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> | ||||
|     ); | ||||
|   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) => { | ||||
|             return (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 sortedSubscriptions = props.subscriptions | ||||
|     .filter((s) => !s.internal) | ||||
|     .sort((a, b) => { | ||||
|       return 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 { t } = useTranslation(); | ||||
|   const navigate = useNavigate(); | ||||
|   const [menuAnchorEl, setMenuAnchorEl] = useState(null); | ||||
| 
 | ||||
|     const subscription = props.subscription; | ||||
|     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 subscription = props.subscription; | ||||
|   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 NotificationGrantAlert = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     return ( | ||||
|         <> | ||||
|             <Alert severity="warning" sx={{paddingTop: 2}}> | ||||
|                 <AlertTitle>{t("alert_grant_title")}</AlertTitle> | ||||
|                 <Typography gutterBottom>{t("alert_grant_description")}</Typography> | ||||
|                 <Button | ||||
|                     sx={{float: 'right'}} | ||||
|                     color="inherit" | ||||
|                     size="small" | ||||
|                     onClick={props.onRequestPermissionClick} | ||||
|                 > | ||||
|                     {t("alert_grant_button")} | ||||
|                 </Button> | ||||
|             </Alert> | ||||
|             <Divider/> | ||||
|         </> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <> | ||||
|       <Alert severity="warning" sx={{ paddingTop: 2 }}> | ||||
|         <AlertTitle>{t("alert_grant_title")}</AlertTitle> | ||||
|         <Typography gutterBottom>{t("alert_grant_description")}</Typography> | ||||
|         <Button | ||||
|           sx={{ float: "right" }} | ||||
|           color="inherit" | ||||
|           size="small" | ||||
|           onClick={props.onRequestPermissionClick} | ||||
|         > | ||||
|           {t("alert_grant_button")} | ||||
|         </Button> | ||||
|       </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> | ||||
|             <Divider/> | ||||
|         </> | ||||
|     ); | ||||
|   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> | ||||
|       <Divider /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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> | ||||
|             <Divider/> | ||||
|         </> | ||||
|     ); | ||||
|   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> | ||||
|       <Divider /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Navigation; | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -1,48 +1,48 @@ | |||
| import {Fade, Menu} from "@mui/material"; | ||||
| import { Fade, Menu } from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| 
 | ||||
| const PopupMenu = (props) => { | ||||
|     const horizontal = props.horizontal ?? "left"; | ||||
|     const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 }; | ||||
|     return ( | ||||
|         <Menu | ||||
|             anchorEl={props.anchorEl} | ||||
|             open={props.open} | ||||
|             onClose={props.onClose} | ||||
|             onClick={props.onClose} | ||||
|             TransitionComponent={Fade} | ||||
|             PaperProps={{ | ||||
|                 elevation: 0, | ||||
|                 sx: { | ||||
|                     overflow: 'visible', | ||||
|                     filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', | ||||
|                     mt: 1.5, | ||||
|                     '& .MuiAvatar-root': { | ||||
|                         width: 32, | ||||
|                         height: 32, | ||||
|                         ml: -0.5, | ||||
|                         mr: 1, | ||||
|                     }, | ||||
|                     '&:before': { | ||||
|                         content: '""', | ||||
|                         display: 'block', | ||||
|                         position: 'absolute', | ||||
|                         top: 0, | ||||
|                         width: 10, | ||||
|                         height: 10, | ||||
|                         bgcolor: 'background.paper', | ||||
|                         transform: 'translateY(-50%) rotate(45deg)', | ||||
|                         zIndex: 0, | ||||
|                         ...arrow | ||||
|                     }, | ||||
|                 }, | ||||
|             }} | ||||
|             transformOrigin={{ horizontal: horizontal, vertical: 'top' }} | ||||
|             anchorOrigin={{ horizontal: horizontal, vertical: 'bottom' }} | ||||
|         > | ||||
|             {props.children} | ||||
|         </Menu> | ||||
|     ); | ||||
|   const horizontal = props.horizontal ?? "left"; | ||||
|   const arrow = horizontal === "right" ? { right: 19 } : { left: 19 }; | ||||
|   return ( | ||||
|     <Menu | ||||
|       anchorEl={props.anchorEl} | ||||
|       open={props.open} | ||||
|       onClose={props.onClose} | ||||
|       onClick={props.onClose} | ||||
|       TransitionComponent={Fade} | ||||
|       PaperProps={{ | ||||
|         elevation: 0, | ||||
|         sx: { | ||||
|           overflow: "visible", | ||||
|           filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", | ||||
|           mt: 1.5, | ||||
|           "& .MuiAvatar-root": { | ||||
|             width: 32, | ||||
|             height: 32, | ||||
|             ml: -0.5, | ||||
|             mr: 1, | ||||
|           }, | ||||
|           "&:before": { | ||||
|             content: '""', | ||||
|             display: "block", | ||||
|             position: "absolute", | ||||
|             top: 0, | ||||
|             width: 10, | ||||
|             height: 10, | ||||
|             bgcolor: "background.paper", | ||||
|             transform: "translateY(-50%) rotate(45deg)", | ||||
|             zIndex: 0, | ||||
|             ...arrow, | ||||
|           }, | ||||
|         }, | ||||
|       }} | ||||
|       transformOrigin={{ horizontal: horizontal, vertical: "top" }} | ||||
|       anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }} | ||||
|     > | ||||
|       {props.children} | ||||
|     </Menu> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default PopupMenu; | ||||
|  |  | |||
|  | @ -1,51 +1,54 @@ | |||
| import * as React from "react"; | ||||
| 
 | ||||
| export const PrefGroup = (props) => { | ||||
|     return ( | ||||
|         <div role="table"> | ||||
|             {props.children} | ||||
|         </div> | ||||
|     ) | ||||
|   return <div role="table">{props.children}</div>; | ||||
| }; | ||||
| 
 | ||||
| export const Pref = (props) => { | ||||
|     const justifyContent = (props.alignTop) ? "normal" : "center"; | ||||
|     return ( | ||||
|         <div | ||||
|             role="row" | ||||
|             style={{ | ||||
|                 display: "flex", | ||||
|                 flexDirection: "row", | ||||
|                 marginTop: "10px", | ||||
|                 marginBottom: "20px", | ||||
|             }} | ||||
|         > | ||||
|             <div | ||||
|                 role="cell" | ||||
|                 id={props.labelId ?? ""} | ||||
|                 aria-label={props.title} | ||||
|                 style={{ | ||||
|                     flex: '1 0 40%', | ||||
|                     display: 'flex', | ||||
|                     flexDirection: 'column', | ||||
|                     justifyContent: justifyContent, | ||||
|                     paddingRight: '30px' | ||||
|                 }} | ||||
|             > | ||||
|                 <div><b>{props.title}</b>{props.subtitle && <em> ({props.subtitle})</em>}</div> | ||||
|                 {props.description && <div><em>{props.description}</em></div>} | ||||
|             </div> | ||||
|             <div | ||||
|                 role="cell" | ||||
|                 style={{ | ||||
|                     flex: '1 0 calc(60% - 50px)', | ||||
|                     display: 'flex', | ||||
|                     flexDirection: 'column', | ||||
|                     justifyContent: justifyContent | ||||
|                 }} | ||||
|             > | ||||
|                 {props.children} | ||||
|             </div> | ||||
|   const justifyContent = props.alignTop ? "normal" : "center"; | ||||
|   return ( | ||||
|     <div | ||||
|       role="row" | ||||
|       style={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "row", | ||||
|         marginTop: "10px", | ||||
|         marginBottom: "20px", | ||||
|       }} | ||||
|     > | ||||
|       <div | ||||
|         role="cell" | ||||
|         id={props.labelId ?? ""} | ||||
|         aria-label={props.title} | ||||
|         style={{ | ||||
|           flex: "1 0 40%", | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|           justifyContent: justifyContent, | ||||
|           paddingRight: "30px", | ||||
|         }} | ||||
|       > | ||||
|         <div> | ||||
|           <b>{props.title}</b> | ||||
|           {props.subtitle && <em> ({props.subtitle})</em>} | ||||
|         </div> | ||||
|     ); | ||||
|         {props.description && ( | ||||
|           <div> | ||||
|             <em>{props.description}</em> | ||||
|           </div> | ||||
|         )} | ||||
|       </div> | ||||
|       <div | ||||
|         role="cell" | ||||
|         style={{ | ||||
|           flex: "1 0 calc(60% - 50px)", | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|           justifyContent: justifyContent, | ||||
|         }} | ||||
|       > | ||||
|         {props.children} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -1,199 +1,239 @@ | |||
| import * as React from 'react'; | ||||
| import {useState} from 'react'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import TextField from '@mui/material/TextField'; | ||||
| import Dialog from '@mui/material/Dialog'; | ||||
| import DialogContent from '@mui/material/DialogContent'; | ||||
| import DialogContentText from '@mui/material/DialogContentText'; | ||||
| import DialogTitle from '@mui/material/DialogTitle'; | ||||
| import {Alert, FormControl, Select, useMediaQuery} from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| import { useState } from "react"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Dialog from "@mui/material/Dialog"; | ||||
| import DialogContent from "@mui/material/DialogContent"; | ||||
| import DialogContentText from "@mui/material/DialogContentText"; | ||||
| import DialogTitle from "@mui/material/DialogTitle"; | ||||
| import { Alert, FormControl, Select, useMediaQuery } from "@mui/material"; | ||||
| import theme from "./theme"; | ||||
| import {validTopic} from "../app/utils"; | ||||
| import { validTopic } from "../app/utils"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import session from "../app/Session"; | ||||
| import routes from "./routes"; | ||||
| import accountApi, {Permission} from "../app/AccountApi"; | ||||
| import accountApi, { Permission } from "../app/AccountApi"; | ||||
| import ReserveTopicSelect from "./ReserveTopicSelect"; | ||||
| import MenuItem from "@mui/material/MenuItem"; | ||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | ||||
| import ListItemText from "@mui/material/ListItemText"; | ||||
| import {Check, DeleteForever} from "@mui/icons-material"; | ||||
| import {TopicReservedError, UnauthorizedError} from "../app/errors"; | ||||
| import { Check, DeleteForever } from "@mui/icons-material"; | ||||
| import { TopicReservedError, UnauthorizedError } from "../app/errors"; | ||||
| 
 | ||||
| export const ReserveAddDialog = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [topic, setTopic] = useState(props.topic || ""); | ||||
|     const [everyone, setEveryone] = useState(Permission.DENY_ALL); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|     const allowTopicEdit = !props.topic; | ||||
|     const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; | ||||
|     const submitButtonEnabled = validTopic(topic) && !alreadyReserved; | ||||
|   const { t } = useTranslation(); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [topic, setTopic] = useState(props.topic || ""); | ||||
|   const [everyone, setEveryone] = useState(Permission.DENY_ALL); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
|   const allowTopicEdit = !props.topic; | ||||
|   const alreadyReserved = | ||||
|     props.reservations.filter((r) => r.topic === topic).length > 0; | ||||
|   const submitButtonEnabled = validTopic(topic) && !alreadyReserved; | ||||
| 
 | ||||
|     const handleSubmit = async () => { | ||||
|         try { | ||||
|             await accountApi.upsertReservation(topic, everyone); | ||||
|             console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); | ||||
|         } catch (e) { | ||||
|             console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 session.resetAndRedirect(routes.login); | ||||
|             } else if (e instanceof TopicReservedError) { | ||||
|                 setError(t("subscribe_dialog_error_topic_already_reserved")); | ||||
|                 return; | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         props.onClose(); | ||||
|     }; | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       await accountApi.upsertReservation(topic, everyone); | ||||
|       console.debug( | ||||
|         `[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}` | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         session.resetAndRedirect(routes.login); | ||||
|       } else if (e instanceof TopicReservedError) { | ||||
|         setError(t("subscribe_dialog_error_topic_already_reserved")); | ||||
|         return; | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     props.onClose(); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> | ||||
|             <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("prefs_reservations_dialog_description")} | ||||
|                 </DialogContentText> | ||||
|                 {allowTopicEdit && <TextField | ||||
|                     autoFocus | ||||
|                     margin="dense" | ||||
|                     id="topic" | ||||
|                     label={t("prefs_reservations_dialog_topic_label")} | ||||
|                     aria-label={t("prefs_reservations_dialog_topic_label")} | ||||
|                     value={topic} | ||||
|                     onChange={ev => setTopic(ev.target.value)} | ||||
|                     type="url" | ||||
|                     fullWidth | ||||
|                     variant="standard" | ||||
|                 />} | ||||
|                 <ReserveTopicSelect | ||||
|                     value={everyone} | ||||
|                     onChange={setEveryone} | ||||
|                     sx={{mt: 1}} | ||||
|                 /> | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|                 <Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("common_add")}</Button> | ||||
|             </DialogFooter> | ||||
|         </Dialog> | ||||
|     ); | ||||
|   return ( | ||||
|     <Dialog | ||||
|       open={props.open} | ||||
|       onClose={props.onClose} | ||||
|       maxWidth="sm" | ||||
|       fullWidth | ||||
|       fullScreen={fullScreen} | ||||
|     > | ||||
|       <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("prefs_reservations_dialog_description")} | ||||
|         </DialogContentText> | ||||
|         {allowTopicEdit && ( | ||||
|           <TextField | ||||
|             autoFocus | ||||
|             margin="dense" | ||||
|             id="topic" | ||||
|             label={t("prefs_reservations_dialog_topic_label")} | ||||
|             aria-label={t("prefs_reservations_dialog_topic_label")} | ||||
|             value={topic} | ||||
|             onChange={(ev) => setTopic(ev.target.value)} | ||||
|             type="url" | ||||
|             fullWidth | ||||
|             variant="standard" | ||||
|           /> | ||||
|         )} | ||||
|         <ReserveTopicSelect | ||||
|           value={everyone} | ||||
|           onChange={setEveryone} | ||||
|           sx={{ mt: 1 }} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|         <Button onClick={handleSubmit} disabled={!submitButtonEnabled}> | ||||
|           {t("common_add")} | ||||
|         </Button> | ||||
|       </DialogFooter> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const ReserveEditDialog = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|   const { t } = useTranslation(); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [everyone, setEveryone] = useState( | ||||
|     props.reservation?.everyone || Permission.DENY_ALL | ||||
|   ); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|     const handleSubmit = async () => { | ||||
|         try { | ||||
|             await accountApi.upsertReservation(props.reservation.topic, everyone); | ||||
|             console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); | ||||
|         } catch (e) { | ||||
|             console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 session.resetAndRedirect(routes.login); | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         props.onClose(); | ||||
|     }; | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       await accountApi.upsertReservation(props.reservation.topic, everyone); | ||||
|       console.debug( | ||||
|         `[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}` | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         session.resetAndRedirect(routes.login); | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     props.onClose(); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> | ||||
|             <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("prefs_reservations_dialog_description")} | ||||
|                 </DialogContentText> | ||||
|                 <ReserveTopicSelect | ||||
|                     value={everyone} | ||||
|                     onChange={setEveryone} | ||||
|                     sx={{mt: 1}} | ||||
|                 /> | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|                 <Button onClick={handleSubmit}>{t("common_save")}</Button> | ||||
|             </DialogFooter> | ||||
|         </Dialog> | ||||
|     ); | ||||
|   return ( | ||||
|     <Dialog | ||||
|       open={props.open} | ||||
|       onClose={props.onClose} | ||||
|       maxWidth="sm" | ||||
|       fullWidth | ||||
|       fullScreen={fullScreen} | ||||
|     > | ||||
|       <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("prefs_reservations_dialog_description")} | ||||
|         </DialogContentText> | ||||
|         <ReserveTopicSelect | ||||
|           value={everyone} | ||||
|           onChange={setEveryone} | ||||
|           sx={{ mt: 1 }} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|         <Button onClick={handleSubmit}>{t("common_save")}</Button> | ||||
|       </DialogFooter> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const ReserveDeleteDialog = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [deleteMessages, setDeleteMessages] = useState(false); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|   const { t } = useTranslation(); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [deleteMessages, setDeleteMessages] = useState(false); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|     const handleSubmit = async () => { | ||||
|         try { | ||||
|             await accountApi.deleteReservation(props.topic, deleteMessages); | ||||
|             console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); | ||||
|         } catch (e) { | ||||
|             console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 session.resetAndRedirect(routes.login); | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         props.onClose(); | ||||
|     }; | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       await accountApi.deleteReservation(props.topic, deleteMessages); | ||||
|       console.debug( | ||||
|         `[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}` | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         session.resetAndRedirect(routes.login); | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     props.onClose(); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> | ||||
|             <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("reservation_delete_dialog_description")} | ||||
|                 </DialogContentText> | ||||
|                 <FormControl fullWidth variant="standard"> | ||||
|                     <Select | ||||
|                         value={deleteMessages} | ||||
|                         onChange={(ev) => setDeleteMessages(ev.target.value)} | ||||
|                         sx={{ | ||||
|                             "& .MuiSelect-select": { | ||||
|                                 display: 'flex', | ||||
|                                 alignItems: 'center', | ||||
|                                 paddingTop: "4px", | ||||
|                                 paddingBottom: "4px", | ||||
|                             } | ||||
|                         }} | ||||
|                     > | ||||
|                         <MenuItem value={false}> | ||||
|                             <ListItemIcon><Check/></ListItemIcon> | ||||
|                             <ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/> | ||||
|                         </MenuItem> | ||||
|                         <MenuItem value={true}> | ||||
|                             <ListItemIcon><DeleteForever/></ListItemIcon> | ||||
|                             <ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/> | ||||
|                         </MenuItem> | ||||
|                     </Select> | ||||
|                 </FormControl> | ||||
|                 {!deleteMessages && | ||||
|                     <Alert severity="info" sx={{ mt: 1 }}> | ||||
|                         {t("reservation_delete_dialog_action_keep_description")} | ||||
|                     </Alert> | ||||
|                 } | ||||
|                 {deleteMessages && | ||||
|                     <Alert severity="warning" sx={{ mt: 1 }}> | ||||
|                         {t("reservation_delete_dialog_action_delete_description")} | ||||
|                     </Alert> | ||||
|                 } | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|                 <Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button> | ||||
|             </DialogFooter> | ||||
|         </Dialog> | ||||
|     ); | ||||
|   return ( | ||||
|     <Dialog | ||||
|       open={props.open} | ||||
|       onClose={props.onClose} | ||||
|       maxWidth="sm" | ||||
|       fullWidth | ||||
|       fullScreen={fullScreen} | ||||
|     > | ||||
|       <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("reservation_delete_dialog_description")} | ||||
|         </DialogContentText> | ||||
|         <FormControl fullWidth variant="standard"> | ||||
|           <Select | ||||
|             value={deleteMessages} | ||||
|             onChange={(ev) => setDeleteMessages(ev.target.value)} | ||||
|             sx={{ | ||||
|               "& .MuiSelect-select": { | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 paddingTop: "4px", | ||||
|                 paddingBottom: "4px", | ||||
|               }, | ||||
|             }} | ||||
|           > | ||||
|             <MenuItem value={false}> | ||||
|               <ListItemIcon> | ||||
|                 <Check /> | ||||
|               </ListItemIcon> | ||||
|               <ListItemText | ||||
|                 primary={t("reservation_delete_dialog_action_keep_title")} | ||||
|               /> | ||||
|             </MenuItem> | ||||
|             <MenuItem value={true}> | ||||
|               <ListItemIcon> | ||||
|                 <DeleteForever /> | ||||
|               </ListItemIcon> | ||||
|               <ListItemText | ||||
|                 primary={t("reservation_delete_dialog_action_delete_title")} | ||||
|               /> | ||||
|             </MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         {!deleteMessages && ( | ||||
|           <Alert severity="info" sx={{ mt: 1 }}> | ||||
|             {t("reservation_delete_dialog_action_keep_description")} | ||||
|           </Alert> | ||||
|         )} | ||||
|         {deleteMessages && ( | ||||
|           <Alert severity="warning" sx={{ mt: 1 }}> | ||||
|             {t("reservation_delete_dialog_action_delete_description")} | ||||
|           </Alert> | ||||
|         )} | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|         <Button onClick={handleSubmit} color="error"> | ||||
|           {t("reservation_delete_dialog_submit_button")} | ||||
|         </Button> | ||||
|       </DialogFooter> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,46 +1,55 @@ | |||
| import * as React from 'react'; | ||||
| import {Lock, Public} from "@mui/icons-material"; | ||||
| import * as React from "react"; | ||||
| import { Lock, Public } from "@mui/icons-material"; | ||||
| import Box from "@mui/material/Box"; | ||||
| 
 | ||||
| export const PermissionReadWrite = React.forwardRef((props, ref) => { | ||||
|     return <PermissionInternal icon={Public} ref={ref} {...props}/>; | ||||
|   return <PermissionInternal icon={Public} ref={ref} {...props} />; | ||||
| }); | ||||
| 
 | ||||
| export const PermissionDenyAll = React.forwardRef((props, ref) => { | ||||
|     return <PermissionInternal icon={Lock} ref={ref} {...props}/>; | ||||
|   return <PermissionInternal icon={Lock} ref={ref} {...props} />; | ||||
| }); | ||||
| 
 | ||||
| export const PermissionRead = React.forwardRef((props, ref) => { | ||||
|     return <PermissionInternal icon={Public} text="R" ref={ref} {...props}/>; | ||||
|   return <PermissionInternal icon={Public} text="R" ref={ref} {...props} />; | ||||
| }); | ||||
| 
 | ||||
| export const PermissionWrite = React.forwardRef((props, ref) => { | ||||
|     return <PermissionInternal icon={Public} text="W" ref={ref} {...props}/>; | ||||
|   return <PermissionInternal icon={Public} text="W" ref={ref} {...props} />; | ||||
| }); | ||||
| 
 | ||||
| const PermissionInternal = React.forwardRef((props, ref) => { | ||||
|     const size = props.size ?? "medium"; | ||||
|     const Icon = props.icon; | ||||
|     return ( | ||||
|         <Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}> | ||||
|             <Icon fontSize={size} sx={{ color: "gray" }}/> | ||||
|             {props.text && | ||||
|                 <Box | ||||
|                     sx={{ | ||||
|                         position: "absolute", | ||||
|                         right: "-6px", | ||||
|                         bottom: "5px", | ||||
|                         fontSize: 10, | ||||
|                         fontWeight: 600, | ||||
|                         color: "gray", | ||||
|                         width: "8px", | ||||
|                         height: "8px", | ||||
|                         marginTop: "3px" | ||||
|                     }} | ||||
|                 > | ||||
|                     {props.text} | ||||
|                 </Box> | ||||
|             } | ||||
|   const size = props.size ?? "medium"; | ||||
|   const Icon = props.icon; | ||||
|   return ( | ||||
|     <Box | ||||
|       ref={ref} | ||||
|       {...props} | ||||
|       style={{ | ||||
|         position: "relative", | ||||
|         display: "inline-flex", | ||||
|         verticalAlign: "middle", | ||||
|         height: "24px", | ||||
|       }} | ||||
|     > | ||||
|       <Icon fontSize={size} sx={{ color: "gray" }} /> | ||||
|       {props.text && ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             position: "absolute", | ||||
|             right: "-6px", | ||||
|             bottom: "5px", | ||||
|             fontSize: 10, | ||||
|             fontWeight: 600, | ||||
|             color: "gray", | ||||
|             width: "8px", | ||||
|             height: "8px", | ||||
|             marginTop: "3px", | ||||
|           }} | ||||
|         > | ||||
|           {props.text} | ||||
|         </Box> | ||||
|     ); | ||||
|       )} | ||||
|     </Box> | ||||
|   ); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,49 +1,70 @@ | |||
| import * as React from 'react'; | ||||
| import {FormControl, Select} from "@mui/material"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import * as React from "react"; | ||||
| import { FormControl, Select } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import MenuItem from "@mui/material/MenuItem"; | ||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | ||||
| import ListItemText from "@mui/material/ListItemText"; | ||||
| import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; | ||||
| import {Permission} from "../app/AccountApi"; | ||||
| import { | ||||
|   PermissionDenyAll, | ||||
|   PermissionRead, | ||||
|   PermissionReadWrite, | ||||
|   PermissionWrite, | ||||
| } from "./ReserveIcons"; | ||||
| import { Permission } from "../app/AccountApi"; | ||||
| 
 | ||||
| const ReserveTopicSelect = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const sx = props.sx || {}; | ||||
|     return ( | ||||
|         <FormControl fullWidth variant="standard" sx={sx}> | ||||
|             <Select | ||||
|                 value={props.value} | ||||
|                 onChange={(ev) => props.onChange(ev.target.value)} | ||||
|                 aria-label={t("prefs_reservations_dialog_access_label")} | ||||
|                 sx={{ | ||||
|                     "& .MuiSelect-select": { | ||||
|                         display: 'flex', | ||||
|                         alignItems: 'center', | ||||
|                         paddingTop: "4px", | ||||
|                         paddingBottom: "4px", | ||||
|                     } | ||||
|                 }} | ||||
|             > | ||||
|                 <MenuItem value={Permission.DENY_ALL}> | ||||
|                     <ListItemIcon><PermissionDenyAll/></ListItemIcon> | ||||
|                     <ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/> | ||||
|                 </MenuItem> | ||||
|                 <MenuItem value={Permission.READ_ONLY}> | ||||
|                     <ListItemIcon><PermissionRead/></ListItemIcon> | ||||
|                     <ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/> | ||||
|                 </MenuItem> | ||||
|                 <MenuItem value={Permission.WRITE_ONLY}> | ||||
|                     <ListItemIcon><PermissionWrite/></ListItemIcon> | ||||
|                     <ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/> | ||||
|                 </MenuItem> | ||||
|                 <MenuItem value={Permission.READ_WRITE}> | ||||
|                     <ListItemIcon><PermissionReadWrite/></ListItemIcon> | ||||
|                     <ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/> | ||||
|                 </MenuItem> | ||||
|             </Select> | ||||
|         </FormControl> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   const sx = props.sx || {}; | ||||
|   return ( | ||||
|     <FormControl fullWidth variant="standard" sx={sx}> | ||||
|       <Select | ||||
|         value={props.value} | ||||
|         onChange={(ev) => props.onChange(ev.target.value)} | ||||
|         aria-label={t("prefs_reservations_dialog_access_label")} | ||||
|         sx={{ | ||||
|           "& .MuiSelect-select": { | ||||
|             display: "flex", | ||||
|             alignItems: "center", | ||||
|             paddingTop: "4px", | ||||
|             paddingBottom: "4px", | ||||
|           }, | ||||
|         }} | ||||
|       > | ||||
|         <MenuItem value={Permission.DENY_ALL}> | ||||
|           <ListItemIcon> | ||||
|             <PermissionDenyAll /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={t("prefs_reservations_table_everyone_deny_all")} | ||||
|           /> | ||||
|         </MenuItem> | ||||
|         <MenuItem value={Permission.READ_ONLY}> | ||||
|           <ListItemIcon> | ||||
|             <PermissionRead /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={t("prefs_reservations_table_everyone_read_only")} | ||||
|           /> | ||||
|         </MenuItem> | ||||
|         <MenuItem value={Permission.WRITE_ONLY}> | ||||
|           <ListItemIcon> | ||||
|             <PermissionWrite /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={t("prefs_reservations_table_everyone_write_only")} | ||||
|           /> | ||||
|         </MenuItem> | ||||
|         <MenuItem value={Permission.READ_WRITE}> | ||||
|           <ListItemIcon> | ||||
|             <PermissionReadWrite /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={t("prefs_reservations_table_everyone_read_write")} | ||||
|           /> | ||||
|         </MenuItem> | ||||
|       </Select> | ||||
|     </FormControl> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ReserveTopicSelect; | ||||
|  |  | |||
|  | @ -1,158 +1,167 @@ | |||
| import * as React from 'react'; | ||||
| import {useState} from 'react'; | ||||
| import * as React from "react"; | ||||
| import { useState } from "react"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import routes from "./routes"; | ||||
| import session from "../app/Session"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import {NavLink} from "react-router-dom"; | ||||
| import { NavLink } from "react-router-dom"; | ||||
| import AvatarBox from "./AvatarBox"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import WarningAmberIcon from "@mui/icons-material/WarningAmber"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
| import {InputAdornment} from "@mui/material"; | ||||
| import { InputAdornment } from "@mui/material"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
| import {Visibility, VisibilityOff} from "@mui/icons-material"; | ||||
| import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors"; | ||||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | ||||
| import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; | ||||
| 
 | ||||
| const Signup = () => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [username, setUsername] = useState(""); | ||||
|     const [password, setPassword] = useState(""); | ||||
|     const [confirm, setConfirm] = useState(""); | ||||
|     const [showPassword, setShowPassword] = useState(false); | ||||
|     const [showConfirm, setShowConfirm] = useState(false); | ||||
|   const { t } = useTranslation(); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [confirm, setConfirm] = useState(""); | ||||
|   const [showPassword, setShowPassword] = useState(false); | ||||
|   const [showConfirm, setShowConfirm] = useState(false); | ||||
| 
 | ||||
|     const handleSubmit = async (event) => { | ||||
|         event.preventDefault(); | ||||
|         const user = { username, password }; | ||||
|         try { | ||||
|             await accountApi.create(user.username, user.password); | ||||
|             const token = await accountApi.login(user); | ||||
|             console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); | ||||
|             session.store(user.username, token); | ||||
|             window.location.href = routes.app; | ||||
|         } catch (e) { | ||||
|             console.log(`[Signup] Signup for user ${user.username} failed`, e); | ||||
|             if (e instanceof UserExistsError) { | ||||
|                 setError(t("signup_error_username_taken", { username: e.username })); | ||||
|             } else if ((e instanceof AccountCreateLimitReachedError)) { | ||||
|                 setError(t("signup_error_creation_limit_reached")); | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     if (!config.enable_signup) { | ||||
|         return ( | ||||
|             <AvatarBox> | ||||
|                 <Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography> | ||||
|             </AvatarBox> | ||||
|         ); | ||||
|   const handleSubmit = async (event) => { | ||||
|     event.preventDefault(); | ||||
|     const user = { username, password }; | ||||
|     try { | ||||
|       await accountApi.create(user.username, user.password); | ||||
|       const token = await accountApi.login(user); | ||||
|       console.log( | ||||
|         `[Signup] User signup for user ${user.username} successful, token is ${token}` | ||||
|       ); | ||||
|       session.store(user.username, token); | ||||
|       window.location.href = routes.app; | ||||
|     } catch (e) { | ||||
|       console.log(`[Signup] Signup for user ${user.username} failed`, e); | ||||
|       if (e instanceof UserExistsError) { | ||||
|         setError(t("signup_error_username_taken", { username: e.username })); | ||||
|       } else if (e instanceof AccountCreateLimitReachedError) { | ||||
|         setError(t("signup_error_creation_limit_reached")); | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   if (!config.enable_signup) { | ||||
|     return ( | ||||
|         <AvatarBox> | ||||
|             <Typography sx={{ typography: 'h6' }}> | ||||
|                 {t("signup_title")} | ||||
|             </Typography> | ||||
|             <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     id="username" | ||||
|                     label={t("signup_form_username")} | ||||
|                     name="username" | ||||
|                     value={username} | ||||
|                     onChange={ev => setUsername(ev.target.value.trim())} | ||||
|                     autoFocus | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="password" | ||||
|                     label={t("signup_form_password")} | ||||
|                     type={showPassword ? "text" : "password"} | ||||
|                     id="password" | ||||
|                     autoComplete="new-password" | ||||
|                     value={password} | ||||
|                     onChange={ev => setPassword(ev.target.value.trim())} | ||||
|                     InputProps={{ | ||||
|                         endAdornment: ( | ||||
|                             <InputAdornment position="end"> | ||||
|                                 <IconButton | ||||
|                                     aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                                     onClick={() => setShowPassword(!showPassword)} | ||||
|                                     onMouseDown={(ev) => ev.preventDefault()} | ||||
|                                     edge="end" | ||||
|                                 > | ||||
|                                     {showPassword ? <VisibilityOff /> : <Visibility />} | ||||
|                                 </IconButton> | ||||
|                             </InputAdornment> | ||||
|                         ) | ||||
|                     }} | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     required | ||||
|                     fullWidth | ||||
|                     name="password" | ||||
|                     label={t("signup_form_confirm_password")} | ||||
|                     type={showConfirm ? "text" : "password"} | ||||
|                     id="confirm" | ||||
|                     autoComplete="new-password" | ||||
|                     value={confirm} | ||||
|                     onChange={ev => setConfirm(ev.target.value.trim())} | ||||
|                     InputProps={{ | ||||
|                         endAdornment: ( | ||||
|                             <InputAdornment position="end"> | ||||
|                                 <IconButton | ||||
|                                     aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                                     onClick={() => setShowConfirm(!showConfirm)} | ||||
|                                     onMouseDown={(ev) => ev.preventDefault()} | ||||
|                                     edge="end" | ||||
|                                 > | ||||
|                                     {showConfirm ? <VisibilityOff /> : <Visibility />} | ||||
|                                 </IconButton> | ||||
|                             </InputAdornment> | ||||
|                         ) | ||||
|                     }} | ||||
|                 /> | ||||
|                 <Button | ||||
|                     type="submit" | ||||
|                     fullWidth | ||||
|                     variant="contained" | ||||
|                     disabled={username === "" || password === "" || password !== confirm} | ||||
|                     sx={{mt: 2, mb: 2}} | ||||
|                 > | ||||
|                     {t("signup_form_button_submit")} | ||||
|                 </Button> | ||||
|                 {error && | ||||
|                     <Box sx={{ | ||||
|                         mb: 1, | ||||
|                         display: 'flex', | ||||
|                         flexGrow: 1, | ||||
|                         justifyContent: 'center', | ||||
|                     }}> | ||||
|                         <WarningAmberIcon color="error" sx={{mr: 1}}/> | ||||
|                         <Typography sx={{color: 'error.main'}}>{error}</Typography> | ||||
|                     </Box> | ||||
|                 } | ||||
|             </Box> | ||||
|             {config.enable_login && | ||||
|                 <Typography sx={{mb: 4}}> | ||||
|                     <NavLink to={routes.login} variant="body1"> | ||||
|                         {t("signup_already_have_account")} | ||||
|                     </NavLink> | ||||
|                 </Typography> | ||||
|             } | ||||
|         </AvatarBox> | ||||
|       <AvatarBox> | ||||
|         <Typography sx={{ typography: "h6" }}> | ||||
|           {t("signup_disabled")} | ||||
|         </Typography> | ||||
|       </AvatarBox> | ||||
|     ); | ||||
| } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <AvatarBox> | ||||
|       <Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography> | ||||
|       <Box | ||||
|         component="form" | ||||
|         onSubmit={handleSubmit} | ||||
|         noValidate | ||||
|         sx={{ mt: 1, maxWidth: 400 }} | ||||
|       > | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           required | ||||
|           fullWidth | ||||
|           id="username" | ||||
|           label={t("signup_form_username")} | ||||
|           name="username" | ||||
|           value={username} | ||||
|           onChange={(ev) => setUsername(ev.target.value.trim())} | ||||
|           autoFocus | ||||
|         /> | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           required | ||||
|           fullWidth | ||||
|           name="password" | ||||
|           label={t("signup_form_password")} | ||||
|           type={showPassword ? "text" : "password"} | ||||
|           id="password" | ||||
|           autoComplete="new-password" | ||||
|           value={password} | ||||
|           onChange={(ev) => setPassword(ev.target.value.trim())} | ||||
|           InputProps={{ | ||||
|             endAdornment: ( | ||||
|               <InputAdornment position="end"> | ||||
|                 <IconButton | ||||
|                   aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                   onClick={() => setShowPassword(!showPassword)} | ||||
|                   onMouseDown={(ev) => ev.preventDefault()} | ||||
|                   edge="end" | ||||
|                 > | ||||
|                   {showPassword ? <VisibilityOff /> : <Visibility />} | ||||
|                 </IconButton> | ||||
|               </InputAdornment> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           required | ||||
|           fullWidth | ||||
|           name="password" | ||||
|           label={t("signup_form_confirm_password")} | ||||
|           type={showConfirm ? "text" : "password"} | ||||
|           id="confirm" | ||||
|           autoComplete="new-password" | ||||
|           value={confirm} | ||||
|           onChange={(ev) => setConfirm(ev.target.value.trim())} | ||||
|           InputProps={{ | ||||
|             endAdornment: ( | ||||
|               <InputAdornment position="end"> | ||||
|                 <IconButton | ||||
|                   aria-label={t("signup_form_toggle_password_visibility")} | ||||
|                   onClick={() => setShowConfirm(!showConfirm)} | ||||
|                   onMouseDown={(ev) => ev.preventDefault()} | ||||
|                   edge="end" | ||||
|                 > | ||||
|                   {showConfirm ? <VisibilityOff /> : <Visibility />} | ||||
|                 </IconButton> | ||||
|               </InputAdornment> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|         <Button | ||||
|           type="submit" | ||||
|           fullWidth | ||||
|           variant="contained" | ||||
|           disabled={username === "" || password === "" || password !== confirm} | ||||
|           sx={{ mt: 2, mb: 2 }} | ||||
|         > | ||||
|           {t("signup_form_button_submit")} | ||||
|         </Button> | ||||
|         {error && ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               mb: 1, | ||||
|               display: "flex", | ||||
|               flexGrow: 1, | ||||
|               justifyContent: "center", | ||||
|             }} | ||||
|           > | ||||
|             <WarningAmberIcon color="error" sx={{ mr: 1 }} /> | ||||
|             <Typography sx={{ color: "error.main" }}>{error}</Typography> | ||||
|           </Box> | ||||
|         )} | ||||
|       </Box> | ||||
|       {config.enable_login && ( | ||||
|         <Typography sx={{ mb: 4 }}> | ||||
|           <NavLink to={routes.login} variant="body1"> | ||||
|             {t("signup_already_have_account")} | ||||
|           </NavLink> | ||||
|         </Typography> | ||||
|       )} | ||||
|     </AvatarBox> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Signup; | ||||
|  |  | |||
|  | @ -1,313 +1,388 @@ | |||
| import * as React from 'react'; | ||||
| import {useContext, useState} from 'react'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import TextField from '@mui/material/TextField'; | ||||
| import Dialog from '@mui/material/Dialog'; | ||||
| import DialogContent from '@mui/material/DialogContent'; | ||||
| import DialogContentText from '@mui/material/DialogContentText'; | ||||
| import DialogTitle from '@mui/material/DialogTitle'; | ||||
| import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| import { useContext, useState } from "react"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Dialog from "@mui/material/Dialog"; | ||||
| import DialogContent from "@mui/material/DialogContent"; | ||||
| import DialogContentText from "@mui/material/DialogContentText"; | ||||
| import DialogTitle from "@mui/material/DialogTitle"; | ||||
| import { | ||||
|   Autocomplete, | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   FormGroup, | ||||
|   useMediaQuery, | ||||
| } from "@mui/material"; | ||||
| import theme from "./theme"; | ||||
| import api from "../app/Api"; | ||||
| import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; | ||||
| import { | ||||
|   randomAlphanumericString, | ||||
|   topicUrl, | ||||
|   validTopic, | ||||
|   validUrl, | ||||
| } from "../app/utils"; | ||||
| import userManager from "../app/UserManager"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import poller from "../app/Poller"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import session from "../app/Session"; | ||||
| import routes from "./routes"; | ||||
| import accountApi, {Permission, Role} from "../app/AccountApi"; | ||||
| import accountApi, { Permission, Role } from "../app/AccountApi"; | ||||
| import ReserveTopicSelect from "./ReserveTopicSelect"; | ||||
| import {AccountContext} from "./App"; | ||||
| import {TopicReservedError, UnauthorizedError} from "../app/errors"; | ||||
| import {ReserveLimitChip} from "./SubscriptionPopup"; | ||||
| import { AccountContext } from "./App"; | ||||
| import { TopicReservedError, UnauthorizedError } from "../app/errors"; | ||||
| import { ReserveLimitChip } from "./SubscriptionPopup"; | ||||
| 
 | ||||
| const publicBaseUrl = "https://ntfy.sh"; | ||||
| 
 | ||||
| const SubscribeDialog = (props) => { | ||||
|     const [baseUrl, setBaseUrl] = useState(""); | ||||
|     const [topic, setTopic] = useState(""); | ||||
|     const [showLoginPage, setShowLoginPage] = useState(false); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|   const [baseUrl, setBaseUrl] = useState(""); | ||||
|   const [topic, setTopic] = useState(""); | ||||
|   const [showLoginPage, setShowLoginPage] = useState(false); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|     const handleSuccess = async () => { | ||||
|         console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); | ||||
|         const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; | ||||
|         const subscription = await subscribeTopic(actualBaseUrl, topic); | ||||
|         poller.pollInBackground(subscription); // Dangle!
 | ||||
|         props.onSuccess(subscription); | ||||
|     } | ||||
|   const handleSuccess = async () => { | ||||
|     console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); | ||||
|     const actualBaseUrl = baseUrl ? baseUrl : config.base_url; | ||||
|     const subscription = await subscribeTopic(actualBaseUrl, topic); | ||||
|     poller.pollInBackground(subscription); // Dangle!
 | ||||
|     props.onSuccess(subscription); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> | ||||
|             {!showLoginPage && <SubscribePage | ||||
|                 baseUrl={baseUrl} | ||||
|                 setBaseUrl={setBaseUrl} | ||||
|                 topic={topic} | ||||
|                 setTopic={setTopic} | ||||
|                 subscriptions={props.subscriptions} | ||||
|                 onCancel={props.onCancel} | ||||
|                 onNeedsLogin={() => setShowLoginPage(true)} | ||||
|                 onSuccess={handleSuccess} | ||||
|             />} | ||||
|             {showLoginPage && <LoginPage | ||||
|                 baseUrl={baseUrl} | ||||
|                 topic={topic} | ||||
|                 onBack={() => setShowLoginPage(false)} | ||||
|                 onSuccess={handleSuccess} | ||||
|             />} | ||||
|         </Dialog> | ||||
|     ); | ||||
|   return ( | ||||
|     <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> | ||||
|       {!showLoginPage && ( | ||||
|         <SubscribePage | ||||
|           baseUrl={baseUrl} | ||||
|           setBaseUrl={setBaseUrl} | ||||
|           topic={topic} | ||||
|           setTopic={setTopic} | ||||
|           subscriptions={props.subscriptions} | ||||
|           onCancel={props.onCancel} | ||||
|           onNeedsLogin={() => setShowLoginPage(true)} | ||||
|           onSuccess={handleSuccess} | ||||
|         /> | ||||
|       )} | ||||
|       {showLoginPage && ( | ||||
|         <LoginPage | ||||
|           baseUrl={baseUrl} | ||||
|           topic={topic} | ||||
|           onBack={() => setShowLoginPage(false)} | ||||
|           onSuccess={handleSuccess} | ||||
|         /> | ||||
|       )} | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const SubscribePage = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const { account } = useContext(AccountContext); | ||||
|     const [error, setError] = useState(""); | ||||
|     const [reserveTopicVisible, setReserveTopicVisible] = useState(false); | ||||
|     const [anotherServerVisible, setAnotherServerVisible] = useState(false); | ||||
|     const [everyone, setEveryone] = useState(Permission.DENY_ALL); | ||||
|     const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; | ||||
|     const topic = props.topic; | ||||
|     const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); | ||||
|     const existingBaseUrls = Array | ||||
|         .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) | ||||
|         .filter(s => s !== config.base_url); | ||||
|     const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); | ||||
|     const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); | ||||
|   const { t } = useTranslation(); | ||||
|   const { account } = useContext(AccountContext); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [reserveTopicVisible, setReserveTopicVisible] = useState(false); | ||||
|   const [anotherServerVisible, setAnotherServerVisible] = useState(false); | ||||
|   const [everyone, setEveryone] = useState(Permission.DENY_ALL); | ||||
|   const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; | ||||
|   const topic = props.topic; | ||||
|   const existingTopicUrls = props.subscriptions.map((s) => | ||||
|     topicUrl(s.baseUrl, s.topic) | ||||
|   ); | ||||
|   const existingBaseUrls = Array.from( | ||||
|     new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)]) | ||||
|   ).filter((s) => s !== config.base_url); | ||||
|   const showReserveTopicCheckbox = | ||||
|     config.enable_reservations && | ||||
|     !anotherServerVisible && | ||||
|     (config.enable_payments || account); | ||||
|   const reserveTopicEnabled = | ||||
|     session.exists() && | ||||
|     (account?.role === Role.ADMIN || | ||||
|       (account?.role === Role.USER && | ||||
|         (account?.stats.reservations_remaining || 0) > 0)); | ||||
| 
 | ||||
|     const handleSubscribe = async () => { | ||||
|         const user = await userManager.get(baseUrl); // May be undefined
 | ||||
|         const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); | ||||
|   const handleSubscribe = async () => { | ||||
|     const user = await userManager.get(baseUrl); // May be undefined
 | ||||
|     const username = user | ||||
|       ? user.username | ||||
|       : t("subscribe_dialog_error_user_anonymous"); | ||||
| 
 | ||||
|         // Check read access to topic
 | ||||
|         const success = await api.topicAuth(baseUrl, topic, user); | ||||
|         if (!success) { | ||||
|             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); | ||||
|             if (user) { | ||||
|                 setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); | ||||
|                 return; | ||||
|             } else { | ||||
|                 props.onNeedsLogin(); | ||||
|                 return; | ||||
|             } | ||||
|     // Check read access to topic
 | ||||
|     const success = await api.topicAuth(baseUrl, topic, user); | ||||
|     if (!success) { | ||||
|       console.log( | ||||
|         `[SubscribeDialog] Login to ${topicUrl( | ||||
|           baseUrl, | ||||
|           topic | ||||
|         )} failed for user ${username}` | ||||
|       ); | ||||
|       if (user) { | ||||
|         setError( | ||||
|           t("subscribe_dialog_error_user_not_authorized", { | ||||
|             username: username, | ||||
|           }) | ||||
|         ); | ||||
|         return; | ||||
|       } else { | ||||
|         props.onNeedsLogin(); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Reserve topic (if requested)
 | ||||
|     if ( | ||||
|       session.exists() && | ||||
|       baseUrl === config.base_url && | ||||
|       reserveTopicVisible | ||||
|     ) { | ||||
|       console.log( | ||||
|         `[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}` | ||||
|       ); | ||||
|       try { | ||||
|         await accountApi.upsertReservation(topic, everyone); | ||||
|       } catch (e) { | ||||
|         console.log(`[SubscribeDialog] Error reserving topic`, e); | ||||
|         if (e instanceof UnauthorizedError) { | ||||
|           session.resetAndRedirect(routes.login); | ||||
|         } else if (e instanceof TopicReservedError) { | ||||
|           setError(t("subscribe_dialog_error_topic_already_reserved")); | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|         // Reserve topic (if requested)
 | ||||
|         if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { | ||||
|             console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); | ||||
|             try { | ||||
|                 await accountApi.upsertReservation(topic, everyone); | ||||
|             } catch (e) { | ||||
|                 console.log(`[SubscribeDialog] Error reserving topic`, e); | ||||
|                 if (e instanceof UnauthorizedError) { | ||||
|                     session.resetAndRedirect(routes.login); | ||||
|                 } else if (e instanceof TopicReservedError) { | ||||
|                     setError(t("subscribe_dialog_error_topic_already_reserved")); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); | ||||
|         props.onSuccess(); | ||||
|     }; | ||||
| 
 | ||||
|     const handleUseAnotherChanged = (e) => { | ||||
|         props.setBaseUrl(""); | ||||
|         setAnotherServerVisible(e.target.checked); | ||||
|     }; | ||||
| 
 | ||||
|     const subscribeButtonEnabled = (() => { | ||||
|         if (anotherServerVisible) { | ||||
|             const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); | ||||
|             return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; | ||||
|         } else { | ||||
|             const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); | ||||
|             return validTopic(topic) && !isExistingTopicUrl; | ||||
|         } | ||||
|     })(); | ||||
| 
 | ||||
|     const updateBaseUrl = (ev, newVal) => { | ||||
|         if (validUrl(newVal)) { | ||||
|           props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
 | ||||
|         } else { | ||||
|           props.setBaseUrl(newVal); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("subscribe_dialog_subscribe_description")} | ||||
|                 </DialogContentText> | ||||
|                 <div style={{display: 'flex', paddingBottom: "8px"}} role="row"> | ||||
|                     <TextField | ||||
|                         autoFocus | ||||
|                         margin="dense" | ||||
|                         id="topic" | ||||
|                         placeholder={t("subscribe_dialog_subscribe_topic_placeholder")} | ||||
|                         value={props.topic} | ||||
|                         onChange={ev => props.setTopic(ev.target.value)} | ||||
|                         type="text" | ||||
|                         fullWidth | ||||
|                         variant="standard" | ||||
|                         inputProps={{ | ||||
|                             maxLength: 64, | ||||
|                             "aria-label": t("subscribe_dialog_subscribe_topic_placeholder") | ||||
|                         }} | ||||
|                     /> | ||||
|                     <Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}> | ||||
|                         {t("subscribe_dialog_subscribe_button_generate_topic_name")} | ||||
|                     </Button> | ||||
|                 </div> | ||||
|                 {showReserveTopicCheckbox && | ||||
|                     <FormGroup> | ||||
|                         <FormControlLabel | ||||
|                             variant="standard" | ||||
|                             control={ | ||||
|                                 <Checkbox | ||||
|                                     fullWidth | ||||
|                                     disabled={!reserveTopicEnabled} | ||||
|                                     checked={reserveTopicVisible} | ||||
|                                     onChange={(ev) => setReserveTopicVisible(ev.target.checked)} | ||||
|                                     inputProps={{ | ||||
|                                         "aria-label": t("reserve_dialog_checkbox_label") | ||||
|                                     }} | ||||
|                                 /> | ||||
|                             } | ||||
|                             label={ | ||||
|                                 <> | ||||
|                                     {t("reserve_dialog_checkbox_label")} | ||||
|                                     <ReserveLimitChip/> | ||||
|                                 </> | ||||
|                             } | ||||
|                         /> | ||||
|                         {reserveTopicVisible && | ||||
|                             <ReserveTopicSelect | ||||
|                                 value={everyone} | ||||
|                                 onChange={setEveryone} | ||||
|                             /> | ||||
|                         } | ||||
|                     </FormGroup> | ||||
|                 } | ||||
|                 {!reserveTopicVisible && | ||||
|                     <FormGroup> | ||||
|                         <FormControlLabel | ||||
|                             control={ | ||||
|                                 <Checkbox | ||||
|                                     onChange={handleUseAnotherChanged} | ||||
|                                     inputProps={{ | ||||
|                                         "aria-label": t("subscribe_dialog_subscribe_use_another_label") | ||||
|                                     }} | ||||
|                                 /> | ||||
|                             } | ||||
|                             label={t("subscribe_dialog_subscribe_use_another_label")}/> | ||||
|                         {anotherServerVisible && <Autocomplete | ||||
|                             freeSolo | ||||
|                             options={existingBaseUrls} | ||||
|                             inputValue={props.baseUrl} | ||||
|                             onInputChange={updateBaseUrl} | ||||
|                             renderInput={(params) => | ||||
|                                 <TextField | ||||
|                                     {...params} | ||||
|                                     placeholder={config.base_url} | ||||
|                                     variant="standard" | ||||
|                                     aria-label={t("subscribe_dialog_subscribe_base_url_label")} | ||||
|                                 /> | ||||
|                             } | ||||
|                         />} | ||||
|                     </FormGroup> | ||||
|                 } | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> | ||||
|                 <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button> | ||||
|             </DialogFooter> | ||||
|         </> | ||||
|     console.log( | ||||
|       `[SubscribeDialog] Successful login to ${topicUrl( | ||||
|         baseUrl, | ||||
|         topic | ||||
|       )} for user ${username}` | ||||
|     ); | ||||
|     props.onSuccess(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleUseAnotherChanged = (e) => { | ||||
|     props.setBaseUrl(""); | ||||
|     setAnotherServerVisible(e.target.checked); | ||||
|   }; | ||||
| 
 | ||||
|   const subscribeButtonEnabled = (() => { | ||||
|     if (anotherServerVisible) { | ||||
|       const isExistingTopicUrl = existingTopicUrls.includes( | ||||
|         topicUrl(baseUrl, topic) | ||||
|       ); | ||||
|       return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; | ||||
|     } else { | ||||
|       const isExistingTopicUrl = existingTopicUrls.includes( | ||||
|         topicUrl(config.base_url, topic) | ||||
|       ); | ||||
|       return validTopic(topic) && !isExistingTopicUrl; | ||||
|     } | ||||
|   })(); | ||||
| 
 | ||||
|   const updateBaseUrl = (ev, newVal) => { | ||||
|     if (validUrl(newVal)) { | ||||
|       props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
 | ||||
|     } else { | ||||
|       props.setBaseUrl(newVal); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("subscribe_dialog_subscribe_description")} | ||||
|         </DialogContentText> | ||||
|         <div style={{ display: "flex", paddingBottom: "8px" }} role="row"> | ||||
|           <TextField | ||||
|             autoFocus | ||||
|             margin="dense" | ||||
|             id="topic" | ||||
|             placeholder={t("subscribe_dialog_subscribe_topic_placeholder")} | ||||
|             value={props.topic} | ||||
|             onChange={(ev) => props.setTopic(ev.target.value)} | ||||
|             type="text" | ||||
|             fullWidth | ||||
|             variant="standard" | ||||
|             inputProps={{ | ||||
|               maxLength: 64, | ||||
|               "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"), | ||||
|             }} | ||||
|           /> | ||||
|           <Button | ||||
|             onClick={() => { | ||||
|               props.setTopic(randomAlphanumericString(16)); | ||||
|             }} | ||||
|             style={{ flexShrink: "0", marginTop: "0.5em" }} | ||||
|           > | ||||
|             {t("subscribe_dialog_subscribe_button_generate_topic_name")} | ||||
|           </Button> | ||||
|         </div> | ||||
|         {showReserveTopicCheckbox && ( | ||||
|           <FormGroup> | ||||
|             <FormControlLabel | ||||
|               variant="standard" | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                   fullWidth | ||||
|                   disabled={!reserveTopicEnabled} | ||||
|                   checked={reserveTopicVisible} | ||||
|                   onChange={(ev) => setReserveTopicVisible(ev.target.checked)} | ||||
|                   inputProps={{ | ||||
|                     "aria-label": t("reserve_dialog_checkbox_label"), | ||||
|                   }} | ||||
|                 /> | ||||
|               } | ||||
|               label={ | ||||
|                 <> | ||||
|                   {t("reserve_dialog_checkbox_label")} | ||||
|                   <ReserveLimitChip /> | ||||
|                 </> | ||||
|               } | ||||
|             /> | ||||
|             {reserveTopicVisible && ( | ||||
|               <ReserveTopicSelect value={everyone} onChange={setEveryone} /> | ||||
|             )} | ||||
|           </FormGroup> | ||||
|         )} | ||||
|         {!reserveTopicVisible && ( | ||||
|           <FormGroup> | ||||
|             <FormControlLabel | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                   onChange={handleUseAnotherChanged} | ||||
|                   inputProps={{ | ||||
|                     "aria-label": t( | ||||
|                       "subscribe_dialog_subscribe_use_another_label" | ||||
|                     ), | ||||
|                   }} | ||||
|                 /> | ||||
|               } | ||||
|               label={t("subscribe_dialog_subscribe_use_another_label")} | ||||
|             /> | ||||
|             {anotherServerVisible && ( | ||||
|               <Autocomplete | ||||
|                 freeSolo | ||||
|                 options={existingBaseUrls} | ||||
|                 inputValue={props.baseUrl} | ||||
|                 onInputChange={updateBaseUrl} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     placeholder={config.base_url} | ||||
|                     variant="standard" | ||||
|                     aria-label={t("subscribe_dialog_subscribe_base_url_label")} | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           </FormGroup> | ||||
|         )} | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onCancel}> | ||||
|           {t("subscribe_dialog_subscribe_button_cancel")} | ||||
|         </Button> | ||||
|         <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}> | ||||
|           {t("subscribe_dialog_subscribe_button_subscribe")} | ||||
|         </Button> | ||||
|       </DialogFooter> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const LoginPage = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const [username, setUsername] = useState(""); | ||||
|     const [password, setPassword] = useState(""); | ||||
|     const [error, setError] = useState(""); | ||||
|     const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; | ||||
|     const topic = props.topic; | ||||
|   const { t } = useTranslation(); | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [error, setError] = useState(""); | ||||
|   const baseUrl = props.baseUrl ? props.baseUrl : config.base_url; | ||||
|   const topic = props.topic; | ||||
| 
 | ||||
|     const handleLogin = async () => { | ||||
|         const user = {baseUrl, username, password}; | ||||
|         const success = await api.topicAuth(baseUrl, topic, user); | ||||
|         if (!success) { | ||||
|             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); | ||||
|             setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); | ||||
|             return; | ||||
|         } | ||||
|         console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); | ||||
|         await userManager.save(user); | ||||
|         props.onSuccess(); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("subscribe_dialog_login_description")} | ||||
|                 </DialogContentText> | ||||
|                 <TextField | ||||
|                     autoFocus | ||||
|                     margin="dense" | ||||
|                     id="username" | ||||
|                     label={t("subscribe_dialog_login_username_label")} | ||||
|                     value={username} | ||||
|                     onChange={ev => setUsername(ev.target.value)} | ||||
|                     type="text" | ||||
|                     fullWidth | ||||
|                     variant="standard" | ||||
|                     inputProps={{ | ||||
|                         "aria-label": t("subscribe_dialog_login_username_label") | ||||
|                     }} | ||||
|                 /> | ||||
|                 <TextField | ||||
|                     margin="dense" | ||||
|                     id="password" | ||||
|                     label={t("subscribe_dialog_login_password_label")} | ||||
|                     type="password" | ||||
|                     value={password} | ||||
|                     onChange={ev => setPassword(ev.target.value)} | ||||
|                     fullWidth | ||||
|                     variant="standard" | ||||
|                     inputProps={{ | ||||
|                         "aria-label": t("subscribe_dialog_login_password_label") | ||||
|                     }} | ||||
|                 /> | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onBack}>{t("common_back")}</Button> | ||||
|                 <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button> | ||||
|             </DialogFooter> | ||||
|         </> | ||||
|   const handleLogin = async () => { | ||||
|     const user = { baseUrl, username, password }; | ||||
|     const success = await api.topicAuth(baseUrl, topic, user); | ||||
|     if (!success) { | ||||
|       console.log( | ||||
|         `[SubscribeDialog] Login to ${topicUrl( | ||||
|           baseUrl, | ||||
|           topic | ||||
|         )} failed for user ${username}` | ||||
|       ); | ||||
|       setError( | ||||
|         t("subscribe_dialog_error_user_not_authorized", { username: username }) | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|     console.log( | ||||
|       `[SubscribeDialog] Successful login to ${topicUrl( | ||||
|         baseUrl, | ||||
|         topic | ||||
|       )} for user ${username}` | ||||
|     ); | ||||
|     await userManager.save(user); | ||||
|     props.onSuccess(); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("subscribe_dialog_login_description")} | ||||
|         </DialogContentText> | ||||
|         <TextField | ||||
|           autoFocus | ||||
|           margin="dense" | ||||
|           id="username" | ||||
|           label={t("subscribe_dialog_login_username_label")} | ||||
|           value={username} | ||||
|           onChange={(ev) => setUsername(ev.target.value)} | ||||
|           type="text" | ||||
|           fullWidth | ||||
|           variant="standard" | ||||
|           inputProps={{ | ||||
|             "aria-label": t("subscribe_dialog_login_username_label"), | ||||
|           }} | ||||
|         /> | ||||
|         <TextField | ||||
|           margin="dense" | ||||
|           id="password" | ||||
|           label={t("subscribe_dialog_login_password_label")} | ||||
|           type="password" | ||||
|           value={password} | ||||
|           onChange={(ev) => setPassword(ev.target.value)} | ||||
|           fullWidth | ||||
|           variant="standard" | ||||
|           inputProps={{ | ||||
|             "aria-label": t("subscribe_dialog_login_password_label"), | ||||
|           }} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onBack}>{t("common_back")}</Button> | ||||
|         <Button onClick={handleLogin}> | ||||
|           {t("subscribe_dialog_login_button_login")} | ||||
|         </Button> | ||||
|       </DialogFooter> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const subscribeTopic = async (baseUrl, topic) => { | ||||
|     const subscription = await subscriptionManager.add(baseUrl, topic); | ||||
|     if (session.exists()) { | ||||
|         try { | ||||
|             await accountApi.addSubscription(baseUrl, topic); | ||||
|         } catch (e) { | ||||
|             console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 session.resetAndRedirect(routes.login); | ||||
|             } | ||||
|         } | ||||
|   const subscription = await subscriptionManager.add(baseUrl, topic); | ||||
|   if (session.exists()) { | ||||
|     try { | ||||
|       await accountApi.addSubscription(baseUrl, topic); | ||||
|     } catch (e) { | ||||
|       console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         session.resetAndRedirect(routes.login); | ||||
|       } | ||||
|     } | ||||
|     return subscription; | ||||
|   } | ||||
|   return subscription; | ||||
| }; | ||||
| 
 | ||||
| export default SubscribeDialog; | ||||
|  |  | |||
|  | @ -1,292 +1,393 @@ | |||
| import * as React from 'react'; | ||||
| import {useContext, useState} from 'react'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import TextField from '@mui/material/TextField'; | ||||
| import Dialog from '@mui/material/Dialog'; | ||||
| import DialogContent from '@mui/material/DialogContent'; | ||||
| import DialogContentText from '@mui/material/DialogContentText'; | ||||
| import DialogTitle from '@mui/material/DialogTitle'; | ||||
| import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| import { useContext, useState } from "react"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import TextField from "@mui/material/TextField"; | ||||
| import Dialog from "@mui/material/Dialog"; | ||||
| import DialogContent from "@mui/material/DialogContent"; | ||||
| import DialogContentText from "@mui/material/DialogContentText"; | ||||
| import DialogTitle from "@mui/material/DialogTitle"; | ||||
| import { | ||||
|   Chip, | ||||
|   InputAdornment, | ||||
|   Portal, | ||||
|   Snackbar, | ||||
|   useMediaQuery, | ||||
| } from "@mui/material"; | ||||
| import theme from "./theme"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
| import {useTranslation} from "react-i18next"; | ||||
| import accountApi, {Role} from "../app/AccountApi"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import accountApi, { Role } from "../app/AccountApi"; | ||||
| import session from "../app/Session"; | ||||
| import routes from "./routes"; | ||||
| import MenuItem from "@mui/material/MenuItem"; | ||||
| import PopupMenu from "./PopupMenu"; | ||||
| import {formatShortDateTime, shuffle} from "../app/utils"; | ||||
| import { formatShortDateTime, shuffle } from "../app/utils"; | ||||
| import api from "../app/Api"; | ||||
| import {useNavigate} from "react-router-dom"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import IconButton from "@mui/material/IconButton"; | ||||
| import {Clear} from "@mui/icons-material"; | ||||
| import {AccountContext} from "./App"; | ||||
| import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; | ||||
| import {UnauthorizedError} from "../app/errors"; | ||||
| import { Clear } from "@mui/icons-material"; | ||||
| import { AccountContext } from "./App"; | ||||
| import { | ||||
|   ReserveAddDialog, | ||||
|   ReserveDeleteDialog, | ||||
|   ReserveEditDialog, | ||||
| } from "./ReserveDialogs"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| 
 | ||||
| export const SubscriptionPopup = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const { account } = useContext(AccountContext); | ||||
|     const navigate = useNavigate(); | ||||
|     const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); | ||||
|     const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); | ||||
|     const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); | ||||
|     const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); | ||||
|     const [showPublishError, setShowPublishError] = useState(false); | ||||
|     const subscription = props.subscription; | ||||
|     const placement = props.placement ?? "left"; | ||||
|     const reservations = account?.reservations || []; | ||||
|   const { t } = useTranslation(); | ||||
|   const { account } = useContext(AccountContext); | ||||
|   const navigate = useNavigate(); | ||||
|   const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); | ||||
|   const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); | ||||
|   const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); | ||||
|   const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); | ||||
|   const [showPublishError, setShowPublishError] = useState(false); | ||||
|   const subscription = props.subscription; | ||||
|   const placement = props.placement ?? "left"; | ||||
|   const reservations = account?.reservations || []; | ||||
| 
 | ||||
|     const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; | ||||
|     const showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); | ||||
|     const showReservationEdit = config.enable_reservations && !!subscription?.reservation; | ||||
|     const showReservationDelete = config.enable_reservations && !!subscription?.reservation; | ||||
|   const showReservationAdd = | ||||
|     config.enable_reservations && | ||||
|     !subscription?.reservation && | ||||
|     account?.stats.reservations_remaining > 0; | ||||
|   const showReservationAddDisabled = | ||||
|     !showReservationAdd && | ||||
|     config.enable_reservations && | ||||
|     !subscription?.reservation && | ||||
|     (config.enable_payments || account?.stats.reservations_remaining === 0); | ||||
|   const showReservationEdit = | ||||
|     config.enable_reservations && !!subscription?.reservation; | ||||
|   const showReservationDelete = | ||||
|     config.enable_reservations && !!subscription?.reservation; | ||||
| 
 | ||||
|     const handleChangeDisplayName = async () => { | ||||
|         setDisplayNameDialogOpen(true); | ||||
|   const handleChangeDisplayName = async () => { | ||||
|     setDisplayNameDialogOpen(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleReserveAdd = async () => { | ||||
|     setReserveAddDialogOpen(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleReserveEdit = async () => { | ||||
|     setReserveEditDialogOpen(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleReserveDelete = async () => { | ||||
|     setReserveDeleteDialogOpen(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSendTestMessage = async () => { | ||||
|     const baseUrl = props.subscription.baseUrl; | ||||
|     const topic = props.subscription.topic; | ||||
|     const tags = shuffle([ | ||||
|       "grinning", | ||||
|       "octopus", | ||||
|       "upside_down_face", | ||||
|       "palm_tree", | ||||
|       "maple_leaf", | ||||
|       "apple", | ||||
|       "skull", | ||||
|       "warning", | ||||
|       "jack_o_lantern", | ||||
|       "de-server-1", | ||||
|       "backups", | ||||
|       "cron-script", | ||||
|       "script-error", | ||||
|       "phils-automation", | ||||
|       "mouse", | ||||
|       "go-rocks", | ||||
|       "hi-ben", | ||||
|     ]).slice(0, Math.round(Math.random() * 4)); | ||||
|     const priority = shuffle([1, 2, 3, 4, 5])[0]; | ||||
|     const title = shuffle([ | ||||
|       "", | ||||
|       "", | ||||
|       "", // Higher chance of no title
 | ||||
|       "Oh my, another test message?", | ||||
|       "Titles are optional, did you know that?", | ||||
|       "ntfy is open source, and will always be free. Cool, right?", | ||||
|       "I don't really like apples", | ||||
|       "My favorite TV show is The Wire. You should watch it!", | ||||
|       "You can attach files and URLs to messages too", | ||||
|       "You can delay messages up to 3 days", | ||||
|     ])[0]; | ||||
|     const nowSeconds = Math.round(Date.now() / 1000); | ||||
|     const message = shuffle([ | ||||
|       `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( | ||||
|         nowSeconds | ||||
|       )} right now. Is that early or late?`,
 | ||||
|       `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, | ||||
|       `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, | ||||
|       `Alright then, it's ${formatShortDateTime( | ||||
|         nowSeconds | ||||
|       )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
 | ||||
|       `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, | ||||
|       `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, | ||||
|       `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, | ||||
|     ])[0]; | ||||
|     try { | ||||
|       await api.publish(baseUrl, topic, message, { | ||||
|         title: title, | ||||
|         priority: priority, | ||||
|         tags: tags, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       console.log(`[SubscriptionPopup] Error publishing message`, e); | ||||
|       setShowPublishError(true); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|     const handleReserveAdd = async () => { | ||||
|         setReserveAddDialogOpen(true); | ||||
|     } | ||||
| 
 | ||||
|     const handleReserveEdit = async () => { | ||||
|         setReserveEditDialogOpen(true); | ||||
|     } | ||||
| 
 | ||||
|     const handleReserveDelete = async () => { | ||||
|         setReserveDeleteDialogOpen(true); | ||||
|     } | ||||
| 
 | ||||
|     const handleSendTestMessage = async () => { | ||||
|         const baseUrl = props.subscription.baseUrl; | ||||
|         const topic = props.subscription.topic; | ||||
|         const tags = shuffle([ | ||||
|             "grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern", | ||||
|             "de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"]) | ||||
|             .slice(0, Math.round(Math.random() * 4)); | ||||
|         const priority = shuffle([1, 2, 3, 4, 5])[0]; | ||||
|         const title = shuffle([ | ||||
|             "", | ||||
|             "", | ||||
|             "", // Higher chance of no title
 | ||||
|             "Oh my, another test message?", | ||||
|             "Titles are optional, did you know that?", | ||||
|             "ntfy is open source, and will always be free. Cool, right?", | ||||
|             "I don't really like apples", | ||||
|             "My favorite TV show is The Wire. You should watch it!", | ||||
|             "You can attach files and URLs to messages too", | ||||
|             "You can delay messages up to 3 days" | ||||
|         ])[0]; | ||||
|         const nowSeconds = Math.round(Date.now()/1000); | ||||
|         const message = shuffle([ | ||||
|             `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, | ||||
|             `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, | ||||
|             `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, | ||||
|             `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, | ||||
|             `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, | ||||
|             `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, | ||||
|             `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?` | ||||
|         ])[0]; | ||||
|         try { | ||||
|             await api.publish(baseUrl, topic, message, { | ||||
|                 title: title, | ||||
|                 priority: priority, | ||||
|                 tags: tags | ||||
|             }); | ||||
|         } catch (e) { | ||||
|             console.log(`[SubscriptionPopup] Error publishing message`, e); | ||||
|             setShowPublishError(true); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     const handleClearAll = async () => { | ||||
|         console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); | ||||
|         await subscriptionManager.deleteNotifications(props.subscription.id); | ||||
|     }; | ||||
| 
 | ||||
|     const handleUnsubscribe = async () => { | ||||
|         console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); | ||||
|         await subscriptionManager.remove(props.subscription.id); | ||||
|         if (session.exists() && !subscription.internal) { | ||||
|             try { | ||||
|                 await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); | ||||
|             } catch (e) { | ||||
|                 console.log(`[SubscriptionPopup] Error unsubscribing`, e); | ||||
|                 if (e instanceof UnauthorizedError) { | ||||
|                     session.resetAndRedirect(routes.login); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         const newSelected = await subscriptionManager.first(); // May be undefined
 | ||||
|         if (newSelected && !newSelected.internal) { | ||||
|             navigate(routes.forSubscription(newSelected)); | ||||
|         } else { | ||||
|             navigate(routes.app); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <PopupMenu | ||||
|                 horizontal={placement} | ||||
|                 anchorEl={props.anchor} | ||||
|                 open={!!props.anchor} | ||||
|                 onClose={props.onClose} | ||||
|             > | ||||
|                 <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem> | ||||
|                 {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>} | ||||
|                 {showReservationAddDisabled && | ||||
|                     <MenuItem sx={{ cursor: "default" }}> | ||||
|                         <span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span> | ||||
|                         <ReserveLimitChip/> | ||||
|                     </MenuItem> | ||||
|                 } | ||||
|                 {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>} | ||||
|                 {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>} | ||||
|                 <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem> | ||||
|                 <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem> | ||||
|                 <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem> | ||||
|             </PopupMenu> | ||||
|             <Portal> | ||||
|                 <Snackbar | ||||
|                     open={showPublishError} | ||||
|                     autoHideDuration={3000} | ||||
|                     onClose={() => setShowPublishError(false)} | ||||
|                     message={t("message_bar_error_publishing")} | ||||
|                 /> | ||||
|                 <DisplayNameDialog | ||||
|                     open={displayNameDialogOpen} | ||||
|                     subscription={subscription} | ||||
|                     onClose={() => setDisplayNameDialogOpen(false)} | ||||
|                 /> | ||||
|                 {showReservationAdd && | ||||
|                     <ReserveAddDialog | ||||
|                         open={reserveAddDialogOpen} | ||||
|                         topic={subscription.topic} | ||||
|                         reservations={reservations} | ||||
|                         onClose={() => setReserveAddDialogOpen(false)} | ||||
|                     /> | ||||
|                 } | ||||
|                 {showReservationEdit && | ||||
|                     <ReserveEditDialog | ||||
|                         open={reserveEditDialogOpen} | ||||
|                         reservation={subscription.reservation} | ||||
|                         reservations={props.reservations} | ||||
|                         onClose={() => setReserveEditDialogOpen(false)} | ||||
|                     /> | ||||
|                 } | ||||
|                 {showReservationDelete && | ||||
|                     <ReserveDeleteDialog | ||||
|                         open={reserveDeleteDialogOpen} | ||||
|                         topic={subscription.topic} | ||||
|                         onClose={() => setReserveDeleteDialogOpen(false)} | ||||
|                     /> | ||||
|                 } | ||||
|             </Portal> | ||||
|         </> | ||||
|   const handleClearAll = async () => { | ||||
|     console.log( | ||||
|       `[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}` | ||||
|     ); | ||||
|     await subscriptionManager.deleteNotifications(props.subscription.id); | ||||
|   }; | ||||
| 
 | ||||
|   const handleUnsubscribe = async () => { | ||||
|     console.log( | ||||
|       `[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, | ||||
|       props.subscription | ||||
|     ); | ||||
|     await subscriptionManager.remove(props.subscription.id); | ||||
|     if (session.exists() && !subscription.internal) { | ||||
|       try { | ||||
|         await accountApi.deleteSubscription( | ||||
|           props.subscription.baseUrl, | ||||
|           props.subscription.topic | ||||
|         ); | ||||
|       } catch (e) { | ||||
|         console.log(`[SubscriptionPopup] Error unsubscribing`, e); | ||||
|         if (e instanceof UnauthorizedError) { | ||||
|           session.resetAndRedirect(routes.login); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     const newSelected = await subscriptionManager.first(); // May be undefined
 | ||||
|     if (newSelected && !newSelected.internal) { | ||||
|       navigate(routes.forSubscription(newSelected)); | ||||
|     } else { | ||||
|       navigate(routes.app); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <PopupMenu | ||||
|         horizontal={placement} | ||||
|         anchorEl={props.anchor} | ||||
|         open={!!props.anchor} | ||||
|         onClose={props.onClose} | ||||
|       > | ||||
|         <MenuItem onClick={handleChangeDisplayName}> | ||||
|           {t("action_bar_change_display_name")} | ||||
|         </MenuItem> | ||||
|         {showReservationAdd && ( | ||||
|           <MenuItem onClick={handleReserveAdd}> | ||||
|             {t("action_bar_reservation_add")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationAddDisabled && ( | ||||
|           <MenuItem sx={{ cursor: "default" }}> | ||||
|             <span style={{ opacity: 0.3 }}> | ||||
|               {t("action_bar_reservation_add")} | ||||
|             </span> | ||||
|             <ReserveLimitChip /> | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationEdit && ( | ||||
|           <MenuItem onClick={handleReserveEdit}> | ||||
|             {t("action_bar_reservation_edit")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationDelete && ( | ||||
|           <MenuItem onClick={handleReserveDelete}> | ||||
|             {t("action_bar_reservation_delete")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         <MenuItem onClick={handleSendTestMessage}> | ||||
|           {t("action_bar_send_test_notification")} | ||||
|         </MenuItem> | ||||
|         <MenuItem onClick={handleClearAll}> | ||||
|           {t("action_bar_clear_notifications")} | ||||
|         </MenuItem> | ||||
|         <MenuItem onClick={handleUnsubscribe}> | ||||
|           {t("action_bar_unsubscribe")} | ||||
|         </MenuItem> | ||||
|       </PopupMenu> | ||||
|       <Portal> | ||||
|         <Snackbar | ||||
|           open={showPublishError} | ||||
|           autoHideDuration={3000} | ||||
|           onClose={() => setShowPublishError(false)} | ||||
|           message={t("message_bar_error_publishing")} | ||||
|         /> | ||||
|         <DisplayNameDialog | ||||
|           open={displayNameDialogOpen} | ||||
|           subscription={subscription} | ||||
|           onClose={() => setDisplayNameDialogOpen(false)} | ||||
|         /> | ||||
|         {showReservationAdd && ( | ||||
|           <ReserveAddDialog | ||||
|             open={reserveAddDialogOpen} | ||||
|             topic={subscription.topic} | ||||
|             reservations={reservations} | ||||
|             onClose={() => setReserveAddDialogOpen(false)} | ||||
|           /> | ||||
|         )} | ||||
|         {showReservationEdit && ( | ||||
|           <ReserveEditDialog | ||||
|             open={reserveEditDialogOpen} | ||||
|             reservation={subscription.reservation} | ||||
|             reservations={props.reservations} | ||||
|             onClose={() => setReserveEditDialogOpen(false)} | ||||
|           /> | ||||
|         )} | ||||
|         {showReservationDelete && ( | ||||
|           <ReserveDeleteDialog | ||||
|             open={reserveDeleteDialogOpen} | ||||
|             topic={subscription.topic} | ||||
|             onClose={() => setReserveDeleteDialogOpen(false)} | ||||
|           /> | ||||
|         )} | ||||
|       </Portal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const DisplayNameDialog = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const subscription = props.subscription; | ||||
|     const [error, setError] = useState(""); | ||||
|     const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|   const { t } = useTranslation(); | ||||
|   const subscription = props.subscription; | ||||
|   const [error, setError] = useState(""); | ||||
|   const [displayName, setDisplayName] = useState( | ||||
|     subscription.displayName ?? "" | ||||
|   ); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|     const handleSave = async () => { | ||||
|         await subscriptionManager.setDisplayName(subscription.id, displayName); | ||||
|         if (session.exists() && !subscription.internal) { | ||||
|             try { | ||||
|                 console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); | ||||
|                 await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); | ||||
|             } catch (e) { | ||||
|                 console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); | ||||
|                 if (e instanceof UnauthorizedError) { | ||||
|                     session.resetAndRedirect(routes.login); | ||||
|                 } else { | ||||
|                     setError(e.message); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|   const handleSave = async () => { | ||||
|     await subscriptionManager.setDisplayName(subscription.id, displayName); | ||||
|     if (session.exists() && !subscription.internal) { | ||||
|       try { | ||||
|         console.log( | ||||
|           `[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}` | ||||
|         ); | ||||
|         await accountApi.updateSubscription( | ||||
|           subscription.baseUrl, | ||||
|           subscription.topic, | ||||
|           { display_name: displayName } | ||||
|         ); | ||||
|       } catch (e) { | ||||
|         console.log( | ||||
|           `[SubscriptionSettingsDialog] Error updating subscription`, | ||||
|           e | ||||
|         ); | ||||
|         if (e instanceof UnauthorizedError) { | ||||
|           session.resetAndRedirect(routes.login); | ||||
|         } else { | ||||
|           setError(e.message); | ||||
|           return; | ||||
|         } | ||||
|         props.onClose(); | ||||
|       } | ||||
|     } | ||||
|     props.onClose(); | ||||
|   }; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> | ||||
|             <DialogTitle>{t("display_name_dialog_title")}</DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <DialogContentText> | ||||
|                     {t("display_name_dialog_description")} | ||||
|                 </DialogContentText> | ||||
|                 <TextField | ||||
|                     autoFocus | ||||
|                     placeholder={t("display_name_dialog_placeholder")} | ||||
|                     value={displayName} | ||||
|                     onChange={ev => setDisplayName(ev.target.value)} | ||||
|                     type="text" | ||||
|                     fullWidth | ||||
|                     variant="standard" | ||||
|                     inputProps={{ | ||||
|                         maxLength: 64, | ||||
|                         "aria-label": t("display_name_dialog_placeholder") | ||||
|                     }} | ||||
|                     InputProps={{ | ||||
|                         endAdornment: ( | ||||
|                             <InputAdornment position="end"> | ||||
|                                 <IconButton onClick={() => setDisplayName("")} edge="end"> | ||||
|                                     <Clear/> | ||||
|                                 </IconButton> | ||||
|                             </InputAdornment> | ||||
|                         ) | ||||
|                     }} | ||||
|                 /> | ||||
|             </DialogContent> | ||||
|             <DialogFooter status={error}> | ||||
|                 <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|                 <Button onClick={handleSave}>{t("common_save")}</Button> | ||||
|             </DialogFooter> | ||||
|         </Dialog> | ||||
|     ); | ||||
|   return ( | ||||
|     <Dialog | ||||
|       open={props.open} | ||||
|       onClose={props.onClose} | ||||
|       maxWidth="sm" | ||||
|       fullWidth | ||||
|       fullScreen={fullScreen} | ||||
|     > | ||||
|       <DialogTitle>{t("display_name_dialog_title")}</DialogTitle> | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           {t("display_name_dialog_description")} | ||||
|         </DialogContentText> | ||||
|         <TextField | ||||
|           autoFocus | ||||
|           placeholder={t("display_name_dialog_placeholder")} | ||||
|           value={displayName} | ||||
|           onChange={(ev) => setDisplayName(ev.target.value)} | ||||
|           type="text" | ||||
|           fullWidth | ||||
|           variant="standard" | ||||
|           inputProps={{ | ||||
|             maxLength: 64, | ||||
|             "aria-label": t("display_name_dialog_placeholder"), | ||||
|           }} | ||||
|           InputProps={{ | ||||
|             endAdornment: ( | ||||
|               <InputAdornment position="end"> | ||||
|                 <IconButton onClick={() => setDisplayName("")} edge="end"> | ||||
|                   <Clear /> | ||||
|                 </IconButton> | ||||
|               </InputAdornment> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onClose}>{t("common_cancel")}</Button> | ||||
|         <Button onClick={handleSave}>{t("common_save")}</Button> | ||||
|       </DialogFooter> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const ReserveLimitChip = () => { | ||||
|     const { account } = useContext(AccountContext); | ||||
|     if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { | ||||
|         return <></>; | ||||
|     } else if (config.enable_payments) { | ||||
|         return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>; | ||||
|     } else if (account) { | ||||
|         return <LimitReachedChip/>; | ||||
|     } | ||||
|   const { account } = useContext(AccountContext); | ||||
|   if ( | ||||
|     account?.role === Role.ADMIN || | ||||
|     account?.stats.reservations_remaining > 0 | ||||
|   ) { | ||||
|     return <></>; | ||||
|   } else if (config.enable_payments) { | ||||
|     return account?.limits.reservations > 0 ? ( | ||||
|       <LimitReachedChip /> | ||||
|     ) : ( | ||||
|       <ProChip /> | ||||
|     ); | ||||
|   } else if (account) { | ||||
|     return <LimitReachedChip />; | ||||
|   } | ||||
|   return <></>; | ||||
| }; | ||||
| 
 | ||||
| const LimitReachedChip = () => { | ||||
|     const { t } = useTranslation(); | ||||
|     return ( | ||||
|         <Chip | ||||
|             label={t("action_bar_reservation_limit_reached")} | ||||
|             variant="outlined" | ||||
|             color="primary" | ||||
|             sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }} | ||||
|         /> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <Chip | ||||
|       label={t("action_bar_reservation_limit_reached")} | ||||
|       variant="outlined" | ||||
|       color="primary" | ||||
|       sx={{ | ||||
|         opacity: 0.8, | ||||
|         borderWidth: "2px", | ||||
|         height: "24px", | ||||
|         marginLeft: "5px", | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const ProChip = () => { | ||||
|     const { t } = useTranslation(); | ||||
|     return ( | ||||
|         <Chip | ||||
|             label={"ntfy Pro"} | ||||
|             variant="outlined" | ||||
|             color="primary" | ||||
|             sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }} | ||||
|         /> | ||||
|     ); | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <Chip | ||||
|       label={"ntfy Pro"} | ||||
|       variant="outlined" | ||||
|       color="primary" | ||||
|       sx={{ | ||||
|         opacity: 0.8, | ||||
|         fontWeight: "bold", | ||||
|         borderWidth: "2px", | ||||
|         height: "24px", | ||||
|         marginLeft: "5px", | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,367 +1,500 @@ | |||
| import * as React from 'react'; | ||||
| import {useContext, useEffect, useState} from 'react'; | ||||
| import Dialog from '@mui/material/Dialog'; | ||||
| import DialogContent from '@mui/material/DialogContent'; | ||||
| import DialogTitle from '@mui/material/DialogTitle'; | ||||
| import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material"; | ||||
| import * as React from "react"; | ||||
| import { useContext, useEffect, useState } from "react"; | ||||
| import Dialog from "@mui/material/Dialog"; | ||||
| import DialogContent from "@mui/material/DialogContent"; | ||||
| import DialogTitle from "@mui/material/DialogTitle"; | ||||
| import { | ||||
|   Alert, | ||||
|   CardActionArea, | ||||
|   CardContent, | ||||
|   Chip, | ||||
|   Link, | ||||
|   ListItem, | ||||
|   Switch, | ||||
|   useMediaQuery, | ||||
| } from "@mui/material"; | ||||
| import theme from "./theme"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import accountApi, {SubscriptionInterval} from "../app/AccountApi"; | ||||
| import accountApi, { SubscriptionInterval } from "../app/AccountApi"; | ||||
| import session from "../app/Session"; | ||||
| import routes from "./routes"; | ||||
| import Card from "@mui/material/Card"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import {AccountContext} from "./App"; | ||||
| import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils"; | ||||
| import {Trans, useTranslation} from "react-i18next"; | ||||
| import { AccountContext } from "./App"; | ||||
| import { | ||||
|   formatBytes, | ||||
|   formatNumber, | ||||
|   formatPrice, | ||||
|   formatShortDate, | ||||
| } from "../app/utils"; | ||||
| import { Trans, useTranslation } from "react-i18next"; | ||||
| import List from "@mui/material/List"; | ||||
| import {Check, Close} from "@mui/icons-material"; | ||||
| import { Check, Close } from "@mui/icons-material"; | ||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | ||||
| import ListItemText from "@mui/material/ListItemText"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import {NavLink} from "react-router-dom"; | ||||
| import {UnauthorizedError} from "../app/errors"; | ||||
| import { NavLink } from "react-router-dom"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| import DialogContentText from "@mui/material/DialogContentText"; | ||||
| import DialogActions from "@mui/material/DialogActions"; | ||||
| 
 | ||||
| const UpgradeDialog = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const { account } = useContext(AccountContext); // May be undefined!
 | ||||
|     const [error, setError] = useState(""); | ||||
|     const [tiers, setTiers] = useState(null); | ||||
|     const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); | ||||
|     const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
 | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|   const { t } = useTranslation(); | ||||
|   const { account } = useContext(AccountContext); // May be undefined!
 | ||||
|   const [error, setError] = useState(""); | ||||
|   const [tiers, setTiers] = useState(null); | ||||
|   const [interval, setInterval] = useState( | ||||
|     account?.billing?.interval || SubscriptionInterval.YEAR | ||||
|   ); | ||||
|   const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
 | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const fetchTiers = async () => { | ||||
|             setTiers(await accountApi.billingTiers()); | ||||
|         } | ||||
|         fetchTiers(); // Dangle
 | ||||
|     }, []); | ||||
|   useEffect(() => { | ||||
|     const fetchTiers = async () => { | ||||
|       setTiers(await accountApi.billingTiers()); | ||||
|     }; | ||||
|     fetchTiers(); // Dangle
 | ||||
|   }, []); | ||||
| 
 | ||||
|     if (!tiers) { | ||||
|         return <></>; | ||||
|   if (!tiers) { | ||||
|     return <></>; | ||||
|   } | ||||
| 
 | ||||
|   const tiersMap = Object.assign( | ||||
|     ...tiers.map((tier) => ({ [tier.code]: tier })) | ||||
|   ); | ||||
|   const newTier = tiersMap[newTierCode]; // May be undefined
 | ||||
|   const currentTier = account?.tier; // May be undefined
 | ||||
|   const currentInterval = account?.billing?.interval; // May be undefined
 | ||||
|   const currentTierCode = currentTier?.code; // May be undefined
 | ||||
| 
 | ||||
|   // Figure out buttons, labels and the submit action
 | ||||
|   let submitAction, submitButtonLabel, banner; | ||||
|   if (!account) { | ||||
|     submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); | ||||
|     submitAction = Action.REDIRECT_SIGNUP; | ||||
|     banner = null; | ||||
|   } else if ( | ||||
|     currentTierCode === newTierCode && | ||||
|     (currentInterval === undefined || currentInterval === interval) | ||||
|   ) { | ||||
|     submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); | ||||
|     submitAction = null; | ||||
|     banner = currentTierCode ? Banner.PRORATION_INFO : null; | ||||
|   } else if (!currentTierCode) { | ||||
|     submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); | ||||
|     submitAction = Action.CREATE_SUBSCRIPTION; | ||||
|     banner = null; | ||||
|   } else if (!newTierCode) { | ||||
|     submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); | ||||
|     submitAction = Action.CANCEL_SUBSCRIPTION; | ||||
|     banner = Banner.CANCEL_WARNING; | ||||
|   } else { | ||||
|     submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); | ||||
|     submitAction = Action.UPDATE_SUBSCRIPTION; | ||||
|     banner = Banner.PRORATION_INFO; | ||||
|   } | ||||
| 
 | ||||
|   // Exceptional conditions
 | ||||
|   if (loading) { | ||||
|     submitAction = null; | ||||
|   } else if ( | ||||
|     newTier?.code && | ||||
|     account?.reservations?.length > newTier?.limits?.reservations | ||||
|   ) { | ||||
|     submitAction = null; | ||||
|     banner = Banner.RESERVATIONS_WARNING; | ||||
|   } | ||||
| 
 | ||||
|   const handleSubmit = async () => { | ||||
|     if (submitAction === Action.REDIRECT_SIGNUP) { | ||||
|       window.location.href = routes.signup; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier}))); | ||||
|     const newTier = tiersMap[newTierCode]; // May be undefined
 | ||||
|     const currentTier = account?.tier; // May be undefined
 | ||||
|     const currentInterval = account?.billing?.interval; // May be undefined
 | ||||
|     const currentTierCode = currentTier?.code; // May be undefined
 | ||||
| 
 | ||||
|     // Figure out buttons, labels and the submit action
 | ||||
|     let submitAction, submitButtonLabel, banner; | ||||
|     if (!account) { | ||||
|         submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); | ||||
|         submitAction = Action.REDIRECT_SIGNUP; | ||||
|         banner = null; | ||||
|     } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { | ||||
|         submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); | ||||
|         submitAction = null; | ||||
|         banner = (currentTierCode) ? Banner.PRORATION_INFO : null; | ||||
|     } else if (!currentTierCode) { | ||||
|         submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); | ||||
|         submitAction = Action.CREATE_SUBSCRIPTION; | ||||
|         banner = null; | ||||
|     } else if (!newTierCode) { | ||||
|         submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); | ||||
|         submitAction = Action.CANCEL_SUBSCRIPTION; | ||||
|         banner = Banner.CANCEL_WARNING; | ||||
|     } else { | ||||
|         submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); | ||||
|         submitAction = Action.UPDATE_SUBSCRIPTION; | ||||
|         banner = Banner.PRORATION_INFO; | ||||
|     try { | ||||
|       setLoading(true); | ||||
|       if (submitAction === Action.CREATE_SUBSCRIPTION) { | ||||
|         const response = await accountApi.createBillingSubscription( | ||||
|           newTierCode, | ||||
|           interval | ||||
|         ); | ||||
|         window.location.href = response.redirect_url; | ||||
|       } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { | ||||
|         await accountApi.updateBillingSubscription(newTierCode, interval); | ||||
|       } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { | ||||
|         await accountApi.deleteBillingSubscription(); | ||||
|       } | ||||
|       props.onCancel(); | ||||
|     } catch (e) { | ||||
|       console.log(`[UpgradeDialog] Error changing billing subscription`, e); | ||||
|       if (e instanceof UnauthorizedError) { | ||||
|         session.resetAndRedirect(routes.login); | ||||
|       } else { | ||||
|         setError(e.message); | ||||
|       } | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|     // Exceptional conditions
 | ||||
|     if (loading) { | ||||
|         submitAction = null; | ||||
|     } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { | ||||
|         submitAction = null; | ||||
|         banner = Banner.RESERVATIONS_WARNING; | ||||
|     } | ||||
| 
 | ||||
|     const handleSubmit = async () => { | ||||
|         if (submitAction === Action.REDIRECT_SIGNUP) { | ||||
|             window.location.href = routes.signup; | ||||
|             return; | ||||
|         } | ||||
|         try { | ||||
|             setLoading(true); | ||||
|             if (submitAction === Action.CREATE_SUBSCRIPTION) { | ||||
|                 const response = await accountApi.createBillingSubscription(newTierCode, interval); | ||||
|                 window.location.href = response.redirect_url; | ||||
|             } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { | ||||
|                 await accountApi.updateBillingSubscription(newTierCode, interval); | ||||
|             } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { | ||||
|                 await accountApi.deleteBillingSubscription(); | ||||
|             } | ||||
|             props.onCancel(); | ||||
|         } catch (e) { | ||||
|             console.log(`[UpgradeDialog] Error changing billing subscription`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|                 session.resetAndRedirect(routes.login); | ||||
|             } else { | ||||
|                 setError(e.message); | ||||
|             } | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // Figure out discount
 | ||||
|     let discount = 0, upto = false; | ||||
|     if (newTier?.prices) { | ||||
|         discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100); | ||||
|     } else { | ||||
|         let n = 0; | ||||
|         for (const t of tiers) { | ||||
|             if (t.prices) { | ||||
|                 const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100); | ||||
|                 if (tierDiscount > discount) { | ||||
|                     discount = tierDiscount; | ||||
|                     n++; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         upto = n > 1; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog | ||||
|             open={props.open} | ||||
|             onClose={props.onCancel} | ||||
|             maxWidth="lg" | ||||
|             fullScreen={fullScreen} | ||||
|         > | ||||
|             <DialogTitle> | ||||
|                 <div style={{ display: "flex", flexDirection: "row" }}> | ||||
|                     <div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div> | ||||
|                     <div style={{ | ||||
|                         display: "flex", | ||||
|                         flexDirection: "row", | ||||
|                         alignItems: "center", | ||||
|                         marginTop: "4px" | ||||
|                     }}> | ||||
|                         <Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography> | ||||
|                         <Switch | ||||
|                             checked={interval === SubscriptionInterval.YEAR} | ||||
|                             onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} | ||||
|                         /> | ||||
|                         <Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography> | ||||
|                         {discount > 0 && | ||||
|                             <Chip | ||||
|                                 label={upto ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount }) : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })} | ||||
|                                 color="primary" | ||||
|                                 size="small" | ||||
|                                 variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"} | ||||
|                                 sx={{ marginLeft: "5px" }} | ||||
|                             /> | ||||
|                         } | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </DialogTitle> | ||||
|             <DialogContent> | ||||
|                 <div style={{ | ||||
|                     display: "flex", | ||||
|                     flexDirection: "row", | ||||
|                     marginBottom: "8px", | ||||
|                     width: "100%" | ||||
|                 }}> | ||||
|                     {tiers.map(tier => | ||||
|                         <TierCard | ||||
|                             key={`tierCard${tier.code || '_free'}`} | ||||
|                             tier={tier} | ||||
|                             current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
 | ||||
|                             selected={newTierCode === tier.code} // tier.code may be undefined!
 | ||||
|                             interval={interval} | ||||
|                             onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
 | ||||
|                         /> | ||||
|                     )} | ||||
|                 </div> | ||||
|                 {banner === Banner.CANCEL_WARNING && | ||||
|                     <Alert severity="warning" sx={{ fontSize: "1rem" }}> | ||||
|                         <Trans | ||||
|                             i18nKey="account_upgrade_dialog_cancel_warning" | ||||
|                             values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} /> | ||||
|                     </Alert> | ||||
|                 } | ||||
|                 {banner === Banner.PRORATION_INFO && | ||||
|                     <Alert severity="info" sx={{ fontSize: "1rem" }}> | ||||
|                         <Trans i18nKey="account_upgrade_dialog_proration_info" /> | ||||
|                     </Alert> | ||||
|                 } | ||||
|                 {banner === Banner.RESERVATIONS_WARNING && | ||||
|                     <Alert severity="warning" sx={{ fontSize: "1rem" }}> | ||||
|                         <Trans | ||||
|                             i18nKey="account_upgrade_dialog_reservations_warning" | ||||
|                             count={account?.reservations.length - newTier?.limits.reservations} | ||||
|                             components={{ | ||||
|                                 Link: <NavLink to={routes.settings}/>, | ||||
|                             }} | ||||
|                         /> | ||||
|                     </Alert> | ||||
|                 } | ||||
|             </DialogContent> | ||||
|             <Box sx={{ | ||||
|                 display: 'flex', | ||||
|                 flexDirection: 'row', | ||||
|                 justifyContent: 'space-between', | ||||
|                 paddingLeft: '24px', | ||||
|                 paddingBottom: '8px', | ||||
|             }}> | ||||
|                 <DialogContentText | ||||
|                     component="div" | ||||
|                     aria-live="polite" | ||||
|                     sx={{ | ||||
|                         margin: '0px', | ||||
|                         paddingTop: '12px', | ||||
|                         paddingBottom: '4px' | ||||
|                     }} | ||||
|                 > | ||||
|                     {config.billing_contact.indexOf('@') !== -1 && | ||||
|                         <><Trans i18nKey="account_upgrade_dialog_billing_contact_email" components={{ Link: <Link href={`mailto:${config.billing_contact}`}/> }}/>{" "}</> | ||||
|                     } | ||||
|                     {config.billing_contact.match(`^http?s://`) && | ||||
|                         <><Trans i18nKey="account_upgrade_dialog_billing_contact_website" components={{ Link: <Link href={config.billing_contact} target="_blank"/> }}/>{" "}</> | ||||
|                     } | ||||
|                     {error} | ||||
|                 </DialogContentText> | ||||
|                 <DialogActions sx={{paddingRight: 2}}> | ||||
|                     <Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button> | ||||
|                     <Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button> | ||||
|                 </DialogActions> | ||||
|             </Box> | ||||
|         </Dialog> | ||||
|   // Figure out discount
 | ||||
|   let discount = 0, | ||||
|     upto = false; | ||||
|   if (newTier?.prices) { | ||||
|     discount = Math.round( | ||||
|       ((newTier.prices.month * 12) / newTier.prices.year - 1) * 100 | ||||
|     ); | ||||
|   } else { | ||||
|     let n = 0; | ||||
|     for (const t of tiers) { | ||||
|       if (t.prices) { | ||||
|         const tierDiscount = Math.round( | ||||
|           ((t.prices.month * 12) / t.prices.year - 1) * 100 | ||||
|         ); | ||||
|         if (tierDiscount > discount) { | ||||
|           discount = tierDiscount; | ||||
|           n++; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     upto = n > 1; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog | ||||
|       open={props.open} | ||||
|       onClose={props.onCancel} | ||||
|       maxWidth="lg" | ||||
|       fullScreen={fullScreen} | ||||
|     > | ||||
|       <DialogTitle> | ||||
|         <div style={{ display: "flex", flexDirection: "row" }}> | ||||
|           <div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div> | ||||
|           <div | ||||
|             style={{ | ||||
|               display: "flex", | ||||
|               flexDirection: "row", | ||||
|               alignItems: "center", | ||||
|               marginTop: "4px", | ||||
|             }} | ||||
|           > | ||||
|             <Typography component="span" variant="subtitle1"> | ||||
|               {t("account_upgrade_dialog_interval_monthly")} | ||||
|             </Typography> | ||||
|             <Switch | ||||
|               checked={interval === SubscriptionInterval.YEAR} | ||||
|               onChange={(ev) => | ||||
|                 setInterval( | ||||
|                   ev.target.checked | ||||
|                     ? SubscriptionInterval.YEAR | ||||
|                     : SubscriptionInterval.MONTH | ||||
|                 ) | ||||
|               } | ||||
|             /> | ||||
|             <Typography component="span" variant="subtitle1"> | ||||
|               {t("account_upgrade_dialog_interval_yearly")} | ||||
|             </Typography> | ||||
|             {discount > 0 && ( | ||||
|               <Chip | ||||
|                 label={ | ||||
|                   upto | ||||
|                     ? t( | ||||
|                         "account_upgrade_dialog_interval_yearly_discount_save_up_to", | ||||
|                         { discount: discount } | ||||
|                       ) | ||||
|                     : t( | ||||
|                         "account_upgrade_dialog_interval_yearly_discount_save", | ||||
|                         { discount: discount } | ||||
|                       ) | ||||
|                 } | ||||
|                 color="primary" | ||||
|                 size="small" | ||||
|                 variant={ | ||||
|                   interval === SubscriptionInterval.YEAR ? "filled" : "outlined" | ||||
|                 } | ||||
|                 sx={{ marginLeft: "5px" }} | ||||
|               /> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       </DialogTitle> | ||||
|       <DialogContent> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "row", | ||||
|             marginBottom: "8px", | ||||
|             width: "100%", | ||||
|           }} | ||||
|         > | ||||
|           {tiers.map((tier) => ( | ||||
|             <TierCard | ||||
|               key={`tierCard${tier.code || "_free"}`} | ||||
|               tier={tier} | ||||
|               current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
 | ||||
|               selected={newTierCode === tier.code} // tier.code may be undefined!
 | ||||
|               interval={interval} | ||||
|               onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
 | ||||
|             /> | ||||
|           ))} | ||||
|         </div> | ||||
|         {banner === Banner.CANCEL_WARNING && ( | ||||
|           <Alert severity="warning" sx={{ fontSize: "1rem" }}> | ||||
|             <Trans | ||||
|               i18nKey="account_upgrade_dialog_cancel_warning" | ||||
|               values={{ | ||||
|                 date: formatShortDate(account?.billing?.paid_until || 0), | ||||
|               }} | ||||
|             /> | ||||
|           </Alert> | ||||
|         )} | ||||
|         {banner === Banner.PRORATION_INFO && ( | ||||
|           <Alert severity="info" sx={{ fontSize: "1rem" }}> | ||||
|             <Trans i18nKey="account_upgrade_dialog_proration_info" /> | ||||
|           </Alert> | ||||
|         )} | ||||
|         {banner === Banner.RESERVATIONS_WARNING && ( | ||||
|           <Alert severity="warning" sx={{ fontSize: "1rem" }}> | ||||
|             <Trans | ||||
|               i18nKey="account_upgrade_dialog_reservations_warning" | ||||
|               count={ | ||||
|                 account?.reservations.length - newTier?.limits.reservations | ||||
|               } | ||||
|               components={{ | ||||
|                 Link: <NavLink to={routes.settings} />, | ||||
|               }} | ||||
|             /> | ||||
|           </Alert> | ||||
|         )} | ||||
|       </DialogContent> | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: "flex", | ||||
|           flexDirection: "row", | ||||
|           justifyContent: "space-between", | ||||
|           paddingLeft: "24px", | ||||
|           paddingBottom: "8px", | ||||
|         }} | ||||
|       > | ||||
|         <DialogContentText | ||||
|           component="div" | ||||
|           aria-live="polite" | ||||
|           sx={{ | ||||
|             margin: "0px", | ||||
|             paddingTop: "12px", | ||||
|             paddingBottom: "4px", | ||||
|           }} | ||||
|         > | ||||
|           {config.billing_contact.indexOf("@") !== -1 && ( | ||||
|             <> | ||||
|               <Trans | ||||
|                 i18nKey="account_upgrade_dialog_billing_contact_email" | ||||
|                 components={{ | ||||
|                   Link: <Link href={`mailto:${config.billing_contact}`} />, | ||||
|                 }} | ||||
|               />{" "} | ||||
|             </> | ||||
|           )} | ||||
|           {config.billing_contact.match(`^http?s://`) && ( | ||||
|             <> | ||||
|               <Trans | ||||
|                 i18nKey="account_upgrade_dialog_billing_contact_website" | ||||
|                 components={{ | ||||
|                   Link: <Link href={config.billing_contact} target="_blank" />, | ||||
|                 }} | ||||
|               />{" "} | ||||
|             </> | ||||
|           )} | ||||
|           {error} | ||||
|         </DialogContentText> | ||||
|         <DialogActions sx={{ paddingRight: 2 }}> | ||||
|           <Button onClick={props.onCancel}> | ||||
|             {t("account_upgrade_dialog_button_cancel")} | ||||
|           </Button> | ||||
|           <Button onClick={handleSubmit} disabled={!submitAction}> | ||||
|             {submitButtonLabel} | ||||
|           </Button> | ||||
|         </DialogActions> | ||||
|       </Box> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const TierCard = (props) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const tier = props.tier; | ||||
|   const { t } = useTranslation(); | ||||
|   const tier = props.tier; | ||||
| 
 | ||||
|     let cardStyle, labelStyle, labelText; | ||||
|     if (props.selected) { | ||||
|         cardStyle = { background: "#eee", border: "3px solid #338574" }; | ||||
|         labelStyle = { background: "#338574", color: "white" }; | ||||
|         labelText = t("account_upgrade_dialog_tier_selected_label"); | ||||
|     } else if (props.current) { | ||||
|         cardStyle = { border: "3px solid #eee" }; | ||||
|         labelStyle = { background: "#eee", color: "black" }; | ||||
|         labelText = t("account_upgrade_dialog_tier_current_label"); | ||||
|     } else { | ||||
|         cardStyle = { border: "3px solid transparent" }; | ||||
|     } | ||||
|   let cardStyle, labelStyle, labelText; | ||||
|   if (props.selected) { | ||||
|     cardStyle = { background: "#eee", border: "3px solid #338574" }; | ||||
|     labelStyle = { background: "#338574", color: "white" }; | ||||
|     labelText = t("account_upgrade_dialog_tier_selected_label"); | ||||
|   } else if (props.current) { | ||||
|     cardStyle = { border: "3px solid #eee" }; | ||||
|     labelStyle = { background: "#eee", color: "black" }; | ||||
|     labelText = t("account_upgrade_dialog_tier_current_label"); | ||||
|   } else { | ||||
|     cardStyle = { border: "3px solid transparent" }; | ||||
|   } | ||||
| 
 | ||||
|     let monthlyPrice; | ||||
|     if (!tier.prices) { | ||||
|         monthlyPrice = 0; | ||||
|     } else if (props.interval === SubscriptionInterval.YEAR) { | ||||
|         monthlyPrice = tier.prices.year/12; | ||||
|     } else if (props.interval === SubscriptionInterval.MONTH) { | ||||
|         monthlyPrice = tier.prices.month; | ||||
|     } | ||||
|   let monthlyPrice; | ||||
|   if (!tier.prices) { | ||||
|     monthlyPrice = 0; | ||||
|   } else if (props.interval === SubscriptionInterval.YEAR) { | ||||
|     monthlyPrice = tier.prices.year / 12; | ||||
|   } else if (props.interval === SubscriptionInterval.MONTH) { | ||||
|     monthlyPrice = tier.prices.month; | ||||
|   } | ||||
| 
 | ||||
|     return ( | ||||
|         <Box sx={{ | ||||
|             m: "7px", | ||||
|             minWidth: "240px", | ||||
|             flexGrow: 1, | ||||
|             flexShrink: 1, | ||||
|             flexBasis: 0, | ||||
|             borderRadius: "5px", | ||||
|             "&:first-of-type": { ml: 0 }, | ||||
|             "&:last-of-type": { mr: 0 }, | ||||
|             ...cardStyle | ||||
|         }}> | ||||
|             <Card sx={{ height: "100%" }}> | ||||
|                 <CardActionArea sx={{ height: "100%" }}> | ||||
|                     <CardContent onClick={props.onClick} sx={{ height: "100%" }}> | ||||
|                         {labelStyle && | ||||
|                             <div style={{ | ||||
|                                 position: "absolute", | ||||
|                                 top: "0", | ||||
|                                 right: "15px", | ||||
|                                 padding: "2px 10px", | ||||
|                                 borderRadius: "3px", | ||||
|                                 ...labelStyle | ||||
|                             }}>{labelText}</div> | ||||
|                         } | ||||
|                         <Typography variant="subtitle1" component="div"> | ||||
|                             {tier.name || t("account_basics_tier_free")} | ||||
|                         </Typography> | ||||
|                         <div> | ||||
|                             <Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography> | ||||
|                             {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>} | ||||
|                         </div> | ||||
|                         <List dense> | ||||
|                             {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>} | ||||
|                             <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature> | ||||
|                             <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature> | ||||
|                             {tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>} | ||||
|                             <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature> | ||||
|                             {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>} | ||||
|                             {tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>} | ||||
|                         </List> | ||||
|                         {tier.prices && props.interval === SubscriptionInterval.MONTH && | ||||
|                             <Typography variant="body2" color="gray"> | ||||
|                                 {t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })} | ||||
|                             </Typography> | ||||
|                         } | ||||
|                         {tier.prices && props.interval === SubscriptionInterval.YEAR && | ||||
|                             <Typography variant="body2" color="gray"> | ||||
|                                 {t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })} | ||||
|                             </Typography> | ||||
|                         } | ||||
|                     </CardContent> | ||||
|                 </CardActionArea> | ||||
|             </Card> | ||||
|         </Box> | ||||
| 
 | ||||
|     ); | ||||
| } | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         m: "7px", | ||||
|         minWidth: "240px", | ||||
|         flexGrow: 1, | ||||
|         flexShrink: 1, | ||||
|         flexBasis: 0, | ||||
|         borderRadius: "5px", | ||||
|         "&:first-of-type": { ml: 0 }, | ||||
|         "&:last-of-type": { mr: 0 }, | ||||
|         ...cardStyle, | ||||
|       }} | ||||
|     > | ||||
|       <Card sx={{ height: "100%" }}> | ||||
|         <CardActionArea sx={{ height: "100%" }}> | ||||
|           <CardContent onClick={props.onClick} sx={{ height: "100%" }}> | ||||
|             {labelStyle && ( | ||||
|               <div | ||||
|                 style={{ | ||||
|                   position: "absolute", | ||||
|                   top: "0", | ||||
|                   right: "15px", | ||||
|                   padding: "2px 10px", | ||||
|                   borderRadius: "3px", | ||||
|                   ...labelStyle, | ||||
|                 }} | ||||
|               > | ||||
|                 {labelText} | ||||
|               </div> | ||||
|             )} | ||||
|             <Typography variant="subtitle1" component="div"> | ||||
|               {tier.name || t("account_basics_tier_free")} | ||||
|             </Typography> | ||||
|             <div> | ||||
|               <Typography | ||||
|                 component="span" | ||||
|                 variant="h4" | ||||
|                 sx={{ fontWeight: 500, marginRight: "3px" }} | ||||
|               > | ||||
|                 {formatPrice(monthlyPrice)} | ||||
|               </Typography> | ||||
|               {monthlyPrice > 0 && ( | ||||
|                 <>/ {t("account_upgrade_dialog_tier_price_per_month")}</> | ||||
|               )} | ||||
|             </div> | ||||
|             <List dense> | ||||
|               {tier.limits.reservations > 0 && ( | ||||
|                 <Feature> | ||||
|                   {t("account_upgrade_dialog_tier_features_reservations", { | ||||
|                     reservations: tier.limits.reservations, | ||||
|                     count: tier.limits.reservations, | ||||
|                   })} | ||||
|                 </Feature> | ||||
|               )} | ||||
|               <Feature> | ||||
|                 {t("account_upgrade_dialog_tier_features_messages", { | ||||
|                   messages: formatNumber(tier.limits.messages), | ||||
|                   count: tier.limits.messages, | ||||
|                 })} | ||||
|               </Feature> | ||||
|               <Feature> | ||||
|                 {t("account_upgrade_dialog_tier_features_emails", { | ||||
|                   emails: formatNumber(tier.limits.emails), | ||||
|                   count: tier.limits.emails, | ||||
|                 })} | ||||
|               </Feature> | ||||
|               {tier.limits.calls > 0 && ( | ||||
|                 <Feature> | ||||
|                   {t("account_upgrade_dialog_tier_features_calls", { | ||||
|                     calls: formatNumber(tier.limits.calls), | ||||
|                     count: tier.limits.calls, | ||||
|                   })} | ||||
|                 </Feature> | ||||
|               )} | ||||
|               <Feature> | ||||
|                 {t( | ||||
|                   "account_upgrade_dialog_tier_features_attachment_file_size", | ||||
|                   { filesize: formatBytes(tier.limits.attachment_file_size, 0) } | ||||
|                 )} | ||||
|               </Feature> | ||||
|               {tier.limits.reservations === 0 && ( | ||||
|                 <NoFeature> | ||||
|                   {t("account_upgrade_dialog_tier_features_no_reservations")} | ||||
|                 </NoFeature> | ||||
|               )} | ||||
|               {tier.limits.calls === 0 && ( | ||||
|                 <NoFeature> | ||||
|                   {t("account_upgrade_dialog_tier_features_no_calls")} | ||||
|                 </NoFeature> | ||||
|               )} | ||||
|             </List> | ||||
|             {tier.prices && props.interval === SubscriptionInterval.MONTH && ( | ||||
|               <Typography variant="body2" color="gray"> | ||||
|                 {t("account_upgrade_dialog_tier_price_billed_monthly", { | ||||
|                   price: formatPrice(tier.prices.month * 12), | ||||
|                 })} | ||||
|               </Typography> | ||||
|             )} | ||||
|             {tier.prices && props.interval === SubscriptionInterval.YEAR && ( | ||||
|               <Typography variant="body2" color="gray"> | ||||
|                 {t("account_upgrade_dialog_tier_price_billed_yearly", { | ||||
|                   price: formatPrice(tier.prices.year), | ||||
|                   save: formatPrice(tier.prices.month * 12 - tier.prices.year), | ||||
|                 })} | ||||
|               </Typography> | ||||
|             )} | ||||
|           </CardContent> | ||||
|         </CardActionArea> | ||||
|       </Card> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Feature = (props) => { | ||||
|     return <FeatureItem feature={true}>{props.children}</FeatureItem>; | ||||
| } | ||||
|   return <FeatureItem feature={true}>{props.children}</FeatureItem>; | ||||
| }; | ||||
| 
 | ||||
| const NoFeature = (props) => { | ||||
|     return <FeatureItem feature={false}>{props.children}</FeatureItem>; | ||||
| } | ||||
|   return <FeatureItem feature={false}>{props.children}</FeatureItem>; | ||||
| }; | ||||
| 
 | ||||
| const FeatureItem = (props) => { | ||||
|     return ( | ||||
|         <ListItem disableGutters sx={{m: 0, p: 0}}> | ||||
|             <ListItemIcon sx={{minWidth: "24px"}}> | ||||
|                 {props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>} | ||||
|                 {!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>} | ||||
|             </ListItemIcon> | ||||
|             <ListItemText | ||||
|                 sx={{mt: "2px", mb: "2px"}} | ||||
|                 primary={ | ||||
|                     <Typography variant="body1"> | ||||
|                         {props.children} | ||||
|                     </Typography> | ||||
|                 } | ||||
|             /> | ||||
|         </ListItem> | ||||
| 
 | ||||
|     ); | ||||
|   return ( | ||||
|     <ListItem disableGutters sx={{ m: 0, p: 0 }}> | ||||
|       <ListItemIcon sx={{ minWidth: "24px" }}> | ||||
|         {props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />} | ||||
|         {!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />} | ||||
|       </ListItemIcon> | ||||
|       <ListItemText | ||||
|         sx={{ mt: "2px", mb: "2px" }} | ||||
|         primary={<Typography variant="body1">{props.children}</Typography>} | ||||
|       /> | ||||
|     </ListItem> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Action = { | ||||
|     REDIRECT_SIGNUP: 1, | ||||
|     CREATE_SUBSCRIPTION: 2, | ||||
|     UPDATE_SUBSCRIPTION: 3, | ||||
|     CANCEL_SUBSCRIPTION: 4 | ||||
|   REDIRECT_SIGNUP: 1, | ||||
|   CREATE_SUBSCRIPTION: 2, | ||||
|   UPDATE_SUBSCRIPTION: 3, | ||||
|   CANCEL_SUBSCRIPTION: 4, | ||||
| }; | ||||
| 
 | ||||
| const Banner = { | ||||
|     CANCEL_WARNING: 1, | ||||
|     PRORATION_INFO: 2, | ||||
|     RESERVATIONS_WARNING: 3 | ||||
|   CANCEL_WARNING: 1, | ||||
|   PRORATION_INFO: 2, | ||||
|   RESERVATIONS_WARNING: 3, | ||||
| }; | ||||
| 
 | ||||
| export default UpgradeDialog; | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import {useNavigate, useParams} from "react-router-dom"; | ||||
| import {useEffect, useState} from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; | ||||
| import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import routes from "./routes"; | ||||
| import connectionManager from "../app/ConnectionManager"; | ||||
|  | @ -9,7 +9,7 @@ import poller from "../app/Poller"; | |||
| import pruner from "../app/Pruner"; | ||||
| import session from "../app/Session"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
| import {UnauthorizedError} from "../app/errors"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| 
 | ||||
| /** | ||||
|  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection | ||||
|  | @ -17,65 +17,82 @@ import {UnauthorizedError} from "../app/errors"; | |||
|  * to the connection being re-established). | ||||
|  */ | ||||
| export const useConnectionListeners = (account, subscriptions, users) => { | ||||
|     const navigate = useNavigate(); | ||||
|   const navigate = useNavigate(); | ||||
| 
 | ||||
|     // Register listeners for incoming messages, and connection state changes
 | ||||
|     useEffect(() => { | ||||
|             const handleMessage = async (subscriptionId, message) => { | ||||
|                 const subscription = await subscriptionManager.get(subscriptionId); | ||||
|                 if (subscription.internal) { | ||||
|                     await handleInternalMessage(message); | ||||
|                 } else { | ||||
|                     await handleNotification(subscriptionId, message); | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const handleInternalMessage = async (message) => { | ||||
|                 console.log(`[ConnectionListener] Received message on sync topic`, message.message); | ||||
|                 try { | ||||
|                     const data = JSON.parse(message.message); | ||||
|                     if (data.event === "sync") { | ||||
|                         console.log(`[ConnectionListener] Triggering account sync`); | ||||
|                         await accountApi.sync(); | ||||
|                     } else { | ||||
|                         console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); | ||||
|                     } | ||||
|                 } catch (e) { | ||||
|                     console.log(`[ConnectionListener] Error parsing sync topic message`, e); | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const handleNotification = async (subscriptionId, notification) => { | ||||
|                 const added = await subscriptionManager.addNotification(subscriptionId, notification); | ||||
|                 if (added) { | ||||
|                     const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); | ||||
|                     await notifier.notify(subscriptionId, notification, defaultClickAction) | ||||
|                 } | ||||
|             }; | ||||
|             connectionManager.registerStateListener(subscriptionManager.updateState); | ||||
|             connectionManager.registerMessageListener(handleMessage); | ||||
|             return () => { | ||||
|                 connectionManager.resetStateListener(); | ||||
|                 connectionManager.resetMessageListener(); | ||||
|             } | ||||
|         }, | ||||
|         // We have to disable dep checking for "navigate". This is fine, it never changes.
 | ||||
|         // eslint-disable-next-line
 | ||||
|         [] | ||||
|     ); | ||||
| 
 | ||||
|     // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
 | ||||
|     useEffect(() => { | ||||
|         if (!account || !account.sync_topic) { | ||||
|             return; | ||||
|   // Register listeners for incoming messages, and connection state changes
 | ||||
|   useEffect( | ||||
|     () => { | ||||
|       const handleMessage = async (subscriptionId, message) => { | ||||
|         const subscription = await subscriptionManager.get(subscriptionId); | ||||
|         if (subscription.internal) { | ||||
|           await handleInternalMessage(message); | ||||
|         } else { | ||||
|           await handleNotification(subscriptionId, message); | ||||
|         } | ||||
|         subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
 | ||||
|     }, [account]); | ||||
|       }; | ||||
| 
 | ||||
|     // When subscriptions or users change, refresh the connections
 | ||||
|     useEffect(() => { | ||||
|         connectionManager.refresh(subscriptions, users); // Dangle
 | ||||
|     }, [subscriptions, users]); | ||||
|       const handleInternalMessage = async (message) => { | ||||
|         console.log( | ||||
|           `[ConnectionListener] Received message on sync topic`, | ||||
|           message.message | ||||
|         ); | ||||
|         try { | ||||
|           const data = JSON.parse(message.message); | ||||
|           if (data.event === "sync") { | ||||
|             console.log(`[ConnectionListener] Triggering account sync`); | ||||
|             await accountApi.sync(); | ||||
|           } else { | ||||
|             console.log( | ||||
|               `[ConnectionListener] Unknown message type. Doing nothing.` | ||||
|             ); | ||||
|           } | ||||
|         } catch (e) { | ||||
|           console.log( | ||||
|             `[ConnectionListener] Error parsing sync topic message`, | ||||
|             e | ||||
|           ); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       const handleNotification = async (subscriptionId, notification) => { | ||||
|         const added = await subscriptionManager.addNotification( | ||||
|           subscriptionId, | ||||
|           notification | ||||
|         ); | ||||
|         if (added) { | ||||
|           const defaultClickAction = (subscription) => | ||||
|             navigate(routes.forSubscription(subscription)); | ||||
|           await notifier.notify( | ||||
|             subscriptionId, | ||||
|             notification, | ||||
|             defaultClickAction | ||||
|           ); | ||||
|         } | ||||
|       }; | ||||
|       connectionManager.registerStateListener(subscriptionManager.updateState); | ||||
|       connectionManager.registerMessageListener(handleMessage); | ||||
|       return () => { | ||||
|         connectionManager.resetStateListener(); | ||||
|         connectionManager.resetMessageListener(); | ||||
|       }; | ||||
|     }, | ||||
|     // We have to disable dep checking for "navigate". This is fine, it never changes.
 | ||||
|     // eslint-disable-next-line
 | ||||
|     [] | ||||
|   ); | ||||
| 
 | ||||
|   // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
 | ||||
|   useEffect(() => { | ||||
|     if (!account || !account.sync_topic) { | ||||
|       return; | ||||
|     } | ||||
|     subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
 | ||||
|   }, [account]); | ||||
| 
 | ||||
|   // When subscriptions or users change, refresh the connections
 | ||||
|   useEffect(() => { | ||||
|     connectionManager.refresh(subscriptions, users); // Dangle
 | ||||
|   }, [subscriptions, users]); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  | @ -83,35 +100,43 @@ export const useConnectionListeners = (account, subscriptions, users) => { | |||
|  * This will only be run once after the initial page load. | ||||
|  */ | ||||
| export const useAutoSubscribe = (subscriptions, selected) => { | ||||
|     const [hasRun, setHasRun] = useState(false); | ||||
|     const params = useParams(); | ||||
|   const [hasRun, setHasRun] = useState(false); | ||||
|   const params = useParams(); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const loaded = subscriptions !== null && subscriptions !== undefined; | ||||
|         if (!loaded || hasRun) { | ||||
|             return; | ||||
|   useEffect(() => { | ||||
|     const loaded = subscriptions !== null && subscriptions !== undefined; | ||||
|     if (!loaded || hasRun) { | ||||
|       return; | ||||
|     } | ||||
|     setHasRun(true); | ||||
|     const eligible = | ||||
|       params.topic && !selected && !disallowedTopic(params.topic); | ||||
|     if (eligible) { | ||||
|       const baseUrl = params.baseUrl | ||||
|         ? expandSecureUrl(params.baseUrl) | ||||
|         : config.base_url; | ||||
|       console.log( | ||||
|         `[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}` | ||||
|       ); | ||||
|       (async () => { | ||||
|         const subscription = await subscriptionManager.add( | ||||
|           baseUrl, | ||||
|           params.topic | ||||
|         ); | ||||
|         if (session.exists()) { | ||||
|           try { | ||||
|             await accountApi.addSubscription(baseUrl, params.topic); | ||||
|           } catch (e) { | ||||
|             console.log(`[Hooks] Auto-subscribing failed`, e); | ||||
|             if (e instanceof UnauthorizedError) { | ||||
|               session.resetAndRedirect(routes.login); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         setHasRun(true); | ||||
|         const eligible = params.topic && !selected && !disallowedTopic(params.topic); | ||||
|         if (eligible) { | ||||
|             const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; | ||||
|             console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); | ||||
|             (async () => { | ||||
|                 const subscription = await subscriptionManager.add(baseUrl, params.topic); | ||||
|                 if (session.exists()) { | ||||
|                     try { | ||||
|                         await accountApi.addSubscription(baseUrl, params.topic); | ||||
|                     } catch (e) { | ||||
|                         console.log(`[Hooks] Auto-subscribing failed`, e); | ||||
|                         if (e instanceof UnauthorizedError) { | ||||
|                             session.resetAndRedirect(routes.login); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 poller.pollInBackground(subscription); // Dangle!
 | ||||
|             })(); | ||||
|         } | ||||
|     }, [params, subscriptions, selected, hasRun]); | ||||
|         poller.pollInBackground(subscription); // Dangle!
 | ||||
|       })(); | ||||
|     } | ||||
|   }, [params, subscriptions, selected, hasRun]); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  | @ -120,19 +145,19 @@ export const useAutoSubscribe = (subscriptions, selected) => { | |||
|  * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
 | ||||
|  */ | ||||
| export const useBackgroundProcesses = () => { | ||||
|     useEffect(() => { | ||||
|         poller.startWorker(); | ||||
|         pruner.startWorker(); | ||||
|         accountApi.startWorker(); | ||||
|     }, []); | ||||
| } | ||||
|   useEffect(() => { | ||||
|     poller.startWorker(); | ||||
|     pruner.startWorker(); | ||||
|     accountApi.startWorker(); | ||||
|   }, []); | ||||
| }; | ||||
| 
 | ||||
| export const useAccountListener = (setAccount) => { | ||||
|     useEffect(() => { | ||||
|         accountApi.registerListener(setAccount); | ||||
|         accountApi.sync(); // Dangle
 | ||||
|         return () => { | ||||
|             accountApi.resetListener(); | ||||
|         } | ||||
|     }, []); | ||||
| } | ||||
|   useEffect(() => { | ||||
|     accountApi.registerListener(setAccount); | ||||
|     accountApi.sync(); // Dangle
 | ||||
|     return () => { | ||||
|       accountApi.resetListener(); | ||||
|     }; | ||||
|   }, []); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import i18n from 'i18next'; | ||||
| import Backend from 'i18next-http-backend'; | ||||
| import LanguageDetector from 'i18next-browser-languagedetector'; | ||||
| import { initReactI18next } from 'react-i18next'; | ||||
| import i18n from "i18next"; | ||||
| import Backend from "i18next-http-backend"; | ||||
| import LanguageDetector from "i18next-browser-languagedetector"; | ||||
| import { initReactI18next } from "react-i18next"; | ||||
| 
 | ||||
| // Translations using i18next
 | ||||
| // - Options: https://www.i18next.com/overview/configuration-options
 | ||||
|  | @ -12,18 +12,18 @@ import { initReactI18next } from 'react-i18next'; | |||
| // https://github.com/i18next/react-i18next/tree/master/example/react
 | ||||
| 
 | ||||
| i18n | ||||
|     .use(Backend) | ||||
|     .use(LanguageDetector) | ||||
|     .use(initReactI18next) | ||||
|     .init({ | ||||
|         fallbackLng: 'en', | ||||
|         debug: true, | ||||
|         interpolation: { | ||||
|             escapeValue: false, // not needed for react as it escapes by default
 | ||||
|         }, | ||||
|         backend: { | ||||
|             loadPath: '/static/langs/{{lng}}.json', | ||||
|         } | ||||
|     }); | ||||
|   .use(Backend) | ||||
|   .use(LanguageDetector) | ||||
|   .use(initReactI18next) | ||||
|   .init({ | ||||
|     fallbackLng: "en", | ||||
|     debug: true, | ||||
|     interpolation: { | ||||
|       escapeValue: false, // not needed for react as it escapes by default
 | ||||
|     }, | ||||
|     backend: { | ||||
|       loadPath: "/static/langs/{{lng}}.json", | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
| export default i18n; | ||||
|  |  | |||
|  | @ -1,20 +1,20 @@ | |||
| import config from "../app/config"; | ||||
| import {shortUrl} from "../app/utils"; | ||||
| import { shortUrl } from "../app/utils"; | ||||
| 
 | ||||
| const routes = { | ||||
|     login: "/login", | ||||
|     signup: "/signup", | ||||
|     app: config.app_root, | ||||
|     account: "/account", | ||||
|     settings: "/settings", | ||||
|     subscription: "/:topic", | ||||
|     subscriptionExternal: "/:baseUrl/:topic", | ||||
|     forSubscription: (subscription) => { | ||||
|         if (subscription.baseUrl !== config.base_url) { | ||||
|             return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; | ||||
|         } | ||||
|         return `/${subscription.topic}`; | ||||
|   login: "/login", | ||||
|   signup: "/signup", | ||||
|   app: config.app_root, | ||||
|   account: "/account", | ||||
|   settings: "/settings", | ||||
|   subscription: "/:topic", | ||||
|   subscriptionExternal: "/:baseUrl/:topic", | ||||
|   forSubscription: (subscription) => { | ||||
|     if (subscription.baseUrl !== config.base_url) { | ||||
|       return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; | ||||
|     } | ||||
|     return `/${subscription.topic}`; | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export default routes; | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import Typography from "@mui/material/Typography"; | ||||
| import theme from "./theme"; | ||||
| import Container from "@mui/material/Container"; | ||||
| import {Backdrop, styled} from "@mui/material"; | ||||
| import { Backdrop, styled } from "@mui/material"; | ||||
| 
 | ||||
| export const Paragraph = styled(Typography)({ | ||||
|   paddingTop: 8, | ||||
|  | @ -9,14 +9,14 @@ export const Paragraph = styled(Typography)({ | |||
| }); | ||||
| 
 | ||||
| export const VerticallyCenteredContainer = styled(Container)({ | ||||
|   display: 'flex', | ||||
|   display: "flex", | ||||
|   flexGrow: 1, | ||||
|   flexDirection: 'column', | ||||
|   justifyContent: 'center', | ||||
|   alignContent: 'center', | ||||
|   color: theme.palette.text.primary | ||||
|   flexDirection: "column", | ||||
|   justifyContent: "center", | ||||
|   alignContent: "center", | ||||
|   color: theme.palette.text.primary, | ||||
| }); | ||||
| 
 | ||||
| export const LightboxBackdrop = styled(Backdrop)({ | ||||
|   backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5
 | ||||
|   backgroundColor: "rgba(0, 0, 0, 0.8)", // was: 0.5
 | ||||
| }); | ||||
|  |  | |||
|  | @ -1,13 +1,13 @@ | |||
| import { red } from '@mui/material/colors'; | ||||
| import { createTheme } from '@mui/material/styles'; | ||||
| import { red } from "@mui/material/colors"; | ||||
| import { createTheme } from "@mui/material/styles"; | ||||
| 
 | ||||
| const theme = createTheme({ | ||||
|   palette: { | ||||
|     primary: { | ||||
|       main: '#338574', | ||||
|       main: "#338574", | ||||
|     }, | ||||
|     secondary: { | ||||
|       main: '#6cead0', | ||||
|       main: "#6cead0", | ||||
|     }, | ||||
|     error: { | ||||
|       main: red.A400, | ||||
|  | @ -17,19 +17,19 @@ const theme = createTheme({ | |||
|     MuiListItemIcon: { | ||||
|       styleOverrides: { | ||||
|         root: { | ||||
|           minWidth: '36px', | ||||
|           minWidth: "36px", | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     MuiCardContent: { | ||||
|       styleOverrides: { | ||||
|         root: { | ||||
|           ':last-child': { | ||||
|             paddingBottom: '16px' | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|           ":last-child": { | ||||
|             paddingBottom: "16px", | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue