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