Run prettier

This commit is contained in:
nimbleghost 2023-05-23 21:13:01 +02:00
parent 206ea312bf
commit 6f6a2d1f69
49 changed files with 22902 additions and 6633 deletions

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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>
);
};

View file

@ -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>
);
});

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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",
}}
/>
);
};

View file

@ -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;

View file

@ -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();
};
}, []);
};

View file

@ -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;

View file

@ -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;

View file

@ -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
});

View file

@ -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",
},
},
},
},
},
});