-
-
{props.title}{props.subtitle && ({props.subtitle})}
- {props.description &&
{props.description}
}
-
-
- {props.children}
-
+ const justifyContent = props.alignTop ? "normal" : "center";
+ return (
+
+
+
+ {props.title}
+ {props.subtitle && ({props.subtitle})}
- );
+ {props.description && (
+
+ {props.description}
+
+ )}
+
+
+ {props.children}
+
+
+ );
};
diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js
index fc8cb35..cbbf1a8 100644
--- a/web/src/components/Preferences.js
+++ b/web/src/components/Preferences.js
@@ -1,654 +1,832 @@
-import * as React from 'react';
-import {useContext, useEffect, useState} from 'react';
+import * as React from "react";
+import { useContext, useEffect, useState } from "react";
import {
- Alert,
- CardActions,
- CardContent,
- Chip,
- FormControl,
- Select,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- Tooltip,
- useMediaQuery
+ Alert,
+ CardActions,
+ CardContent,
+ Chip,
+ FormControl,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Tooltip,
+ useMediaQuery,
} from "@mui/material";
import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs";
-import {Paragraph} from "./styles";
-import EditIcon from '@mui/icons-material/Edit';
+import { Paragraph } from "./styles";
+import EditIcon from "@mui/icons-material/Edit";
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
-import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
-import {useLiveQuery} from "dexie-react-hooks";
+import { useLiveQuery } from "dexie-react-hooks";
import theme from "./theme";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
-import {playSound, shuffle, sounds, validUrl} from "../app/utils";
-import {useTranslation} from "react-i18next";
+import { playSound, shuffle, sounds, validUrl } from "../app/utils";
+import { useTranslation } from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
-import accountApi, {Permission, Role} from "../app/AccountApi";
-import {Pref, PrefGroup} from "./Pref";
-import {Info} from "@mui/icons-material";
-import {AccountContext} from "./App";
-import {useOutletContext} from "react-router-dom";
-import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
-import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
-import {UnauthorizedError} from "../app/errors";
+import accountApi, { Permission, Role } from "../app/AccountApi";
+import { Pref, PrefGroup } from "./Pref";
+import { Info } from "@mui/icons-material";
+import { AccountContext } from "./App";
+import { useOutletContext } from "react-router-dom";
+import {
+ PermissionDenyAll,
+ PermissionRead,
+ PermissionReadWrite,
+ PermissionWrite,
+} from "./ReserveIcons";
+import {
+ ReserveAddDialog,
+ ReserveDeleteDialog,
+ ReserveEditDialog,
+} from "./ReserveDialogs";
+import { UnauthorizedError } from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager";
-import {subscribeTopic} from "./SubscribeDialog";
+import { subscribeTopic } from "./SubscribeDialog";
const Preferences = () => {
- return (
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+ );
};
const Notifications = () => {
- const { t } = useTranslation();
- return (
-
-
- {t("prefs_notifications_title")}
-
-
-
-
-
-
-
- );
+ const { t } = useTranslation();
+ return (
+
+
+ {t("prefs_notifications_title")}
+
+
+
+
+
+
+
+ );
};
const Sound = () => {
- const { t } = useTranslation();
- const labelId = "prefSound";
- const sound = useLiveQuery(async () => prefs.sound());
- const handleChange = async (ev) => {
- await prefs.setSound(ev.target.value);
- await maybeUpdateAccountSettings({
- notification: {
- sound: ev.target.value
- }
- });
- }
- if (!sound) {
- return null; // While loading
- }
- let description;
- if (sound === "none") {
- description = t("prefs_notifications_sound_description_none");
- } else {
- description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
- }
- return (
-
-
-
-
-
-
playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
-
-
-
-
- )
+ const { t } = useTranslation();
+ const labelId = "prefSound";
+ const sound = useLiveQuery(async () => prefs.sound());
+ const handleChange = async (ev) => {
+ await prefs.setSound(ev.target.value);
+ await maybeUpdateAccountSettings({
+ notification: {
+ sound: ev.target.value,
+ },
+ });
+ };
+ if (!sound) {
+ return null; // While loading
+ }
+ let description;
+ if (sound === "none") {
+ description = t("prefs_notifications_sound_description_none");
+ } else {
+ description = t("prefs_notifications_sound_description_some", {
+ sound: sounds[sound].label,
+ });
+ }
+ return (
+
+
+
+
+
+
playSound(sound)}
+ disabled={sound === "none"}
+ aria-label={t("prefs_notifications_sound_play")}
+ >
+
+
+
+
+ );
};
const MinPriority = () => {
- const { t } = useTranslation();
- const labelId = "prefMinPriority";
- const minPriority = useLiveQuery(async () => prefs.minPriority());
- const handleChange = async (ev) => {
- await prefs.setMinPriority(ev.target.value);
- await maybeUpdateAccountSettings({
- notification: {
- min_priority: ev.target.value
- }
- });
- }
- if (!minPriority) {
- return null; // While loading
- }
- const priorities = {
- 1: t("priority_min"),
- 2: t("priority_low"),
- 3: t("priority_default"),
- 4: t("priority_high"),
- 5: t("priority_max")
- }
- let description;
- if (minPriority === 1) {
- description = t("prefs_notifications_min_priority_description_any");
- } else if (minPriority === 5) {
- description = t("prefs_notifications_min_priority_description_max");
- } else {
- description = t("prefs_notifications_min_priority_description_x_or_higher", {
- number: minPriority,
- name: priorities[minPriority]
- });
- }
- return (
-
-
-
-
-
- )
+ const { t } = useTranslation();
+ const labelId = "prefMinPriority";
+ const minPriority = useLiveQuery(async () => prefs.minPriority());
+ const handleChange = async (ev) => {
+ await prefs.setMinPriority(ev.target.value);
+ await maybeUpdateAccountSettings({
+ notification: {
+ min_priority: ev.target.value,
+ },
+ });
+ };
+ if (!minPriority) {
+ return null; // While loading
+ }
+ const priorities = {
+ 1: t("priority_min"),
+ 2: t("priority_low"),
+ 3: t("priority_default"),
+ 4: t("priority_high"),
+ 5: t("priority_max"),
+ };
+ let description;
+ if (minPriority === 1) {
+ description = t("prefs_notifications_min_priority_description_any");
+ } else if (minPriority === 5) {
+ description = t("prefs_notifications_min_priority_description_max");
+ } else {
+ description = t(
+ "prefs_notifications_min_priority_description_x_or_higher",
+ {
+ number: minPriority,
+ name: priorities[minPriority],
+ }
+ );
+ }
+ return (
+
+
+
+
+
+ );
};
const DeleteAfter = () => {
- const { t } = useTranslation();
- const labelId = "prefDeleteAfter";
- const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
- const handleChange = async (ev) => {
- await prefs.setDeleteAfter(ev.target.value);
- await maybeUpdateAccountSettings({
- notification: {
- delete_after: ev.target.value
- }
- });
+ const { t } = useTranslation();
+ const labelId = "prefDeleteAfter";
+ const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
+ const handleChange = async (ev) => {
+ await prefs.setDeleteAfter(ev.target.value);
+ await maybeUpdateAccountSettings({
+ notification: {
+ delete_after: ev.target.value,
+ },
+ });
+ };
+ if (deleteAfter === null || deleteAfter === undefined) {
+ // !deleteAfter will not work with "0"
+ return null; // While loading
+ }
+ const description = (() => {
+ switch (deleteAfter) {
+ case 0:
+ return t("prefs_notifications_delete_after_never_description");
+ case 10800:
+ return t("prefs_notifications_delete_after_three_hours_description");
+ case 86400:
+ return t("prefs_notifications_delete_after_one_day_description");
+ case 604800:
+ return t("prefs_notifications_delete_after_one_week_description");
+ case 2592000:
+ return t("prefs_notifications_delete_after_one_month_description");
}
- if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
- return null; // While loading
- }
- const description = (() => {
- switch (deleteAfter) {
- case 0: return t("prefs_notifications_delete_after_never_description");
- case 10800: return t("prefs_notifications_delete_after_three_hours_description");
- case 86400: return t("prefs_notifications_delete_after_one_day_description");
- case 604800: return t("prefs_notifications_delete_after_one_week_description");
- case 2592000: return t("prefs_notifications_delete_after_one_month_description");
- }
- })();
- return (
-
-
-
-
-
- )
+ })();
+ return (
+
+
+
+
+
+ );
};
const Users = () => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const users = useLiveQuery(() => userManager.all());
- const handleAddClick = () => {
- setDialogKey(prev => prev+1);
- setDialogOpen(true);
- };
- const handleDialogCancel = () => {
- setDialogOpen(false);
- };
- const handleDialogSubmit = async (user) => {
- setDialogOpen(false);
- try {
- await userManager.save(user);
- console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
- } catch (e) {
- console.log(`[Preferences] Error adding user.`, e);
- }
- };
- return (
-
-
-
- {t("prefs_users_title")}
-
-
- {t("prefs_users_description")}
- {session.exists() && <>{" " + t("prefs_users_description_no_sync")}>}
-
- {users?.length > 0 && }
-
-
-
-
-
-
- );
+ const { t } = useTranslation();
+ const [dialogKey, setDialogKey] = useState(0);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const users = useLiveQuery(() => userManager.all());
+ const handleAddClick = () => {
+ setDialogKey((prev) => prev + 1);
+ setDialogOpen(true);
+ };
+ const handleDialogCancel = () => {
+ setDialogOpen(false);
+ };
+ const handleDialogSubmit = async (user) => {
+ setDialogOpen(false);
+ try {
+ await userManager.save(user);
+ console.debug(
+ `[Preferences] User ${user.username} for ${user.baseUrl} added`
+ );
+ } catch (e) {
+ console.log(`[Preferences] Error adding user.`, e);
+ }
+ };
+ return (
+
+
+
+ {t("prefs_users_title")}
+
+
+ {t("prefs_users_description")}
+ {session.exists() && (
+ <>{" " + t("prefs_users_description_no_sync")}>
+ )}
+
+ {users?.length > 0 && }
+
+
+
+
+
+
+ );
};
const UserTable = (props) => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const [dialogUser, setDialogUser] = useState(null);
+ const { t } = useTranslation();
+ const [dialogKey, setDialogKey] = useState(0);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [dialogUser, setDialogUser] = useState(null);
- const handleEditClick = (user) => {
- setDialogKey(prev => prev+1);
- setDialogUser(user);
- setDialogOpen(true);
- };
+ const handleEditClick = (user) => {
+ setDialogKey((prev) => prev + 1);
+ setDialogUser(user);
+ setDialogOpen(true);
+ };
- const handleDialogCancel = () => {
- setDialogOpen(false);
- };
+ const handleDialogCancel = () => {
+ setDialogOpen(false);
+ };
- const handleDialogSubmit = async (user) => {
- setDialogOpen(false);
- try {
- await userManager.save(user);
- console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
- } catch (e) {
- console.log(`[Preferences] Error updating user.`, e);
- }
- };
+ const handleDialogSubmit = async (user) => {
+ setDialogOpen(false);
+ try {
+ await userManager.save(user);
+ console.debug(
+ `[Preferences] User ${user.username} for ${user.baseUrl} updated`
+ );
+ } catch (e) {
+ console.log(`[Preferences] Error updating user.`, e);
+ }
+ };
- const handleDeleteClick = async (user) => {
- try {
- await userManager.delete(user.baseUrl);
- console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
- } catch (e) {
- console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
- }
- };
+ const handleDeleteClick = async (user) => {
+ try {
+ await userManager.delete(user.baseUrl);
+ console.debug(
+ `[Preferences] User ${user.username} for ${user.baseUrl} deleted`
+ );
+ } catch (e) {
+ console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
+ }
+ };
- return (
-
-
-
- {t("prefs_users_table_user_header")}
- {t("prefs_users_table_base_url_header")}
-
-
-
-
- {props.users?.map(user => (
-
- {user.username}
- {user.baseUrl}
-
- {(!session.exists() || user.baseUrl !== config.base_url) &&
- <>
- handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
-
-
- handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
-
-
- >
- }
- {session.exists() && user.baseUrl === config.base_url &&
-
-
-
-
-
-
- }
-
-
- ))}
-
-
-
- );
+ return (
+
+
+
+
+ {t("prefs_users_table_user_header")}
+
+ {t("prefs_users_table_base_url_header")}
+
+
+
+
+ {props.users?.map((user) => (
+
+
+ {user.username}
+
+
+ {user.baseUrl}
+
+
+ {(!session.exists() || user.baseUrl !== config.base_url) && (
+ <>
+ handleEditClick(user)}
+ aria-label={t("prefs_users_edit_button")}
+ >
+
+
+ handleDeleteClick(user)}
+ aria-label={t("prefs_users_delete_button")}
+ >
+
+
+ >
+ )}
+ {session.exists() && user.baseUrl === config.base_url && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
+ );
};
const UserDialog = (props) => {
- const { t } = useTranslation();
- const [baseUrl, setBaseUrl] = useState("");
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
- const editMode = props.user !== null;
- const addButtonEnabled = (() => {
- if (editMode) {
- return username.length > 0 && password.length > 0;
- }
- const baseUrlValid = validUrl(baseUrl);
- const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
- return baseUrlValid
- && !baseUrlExists
- && username.length > 0
- && password.length > 0;
- })();
- const handleSubmit = async () => {
- props.onSubmit({
- baseUrl: baseUrl,
- username: username,
- password: password
- })
- };
- useEffect(() => {
- if (editMode) {
- setBaseUrl(props.user.baseUrl);
- setUsername(props.user.username);
- setPassword(props.user.password);
- }
- }, [editMode, props.user]);
+ const { t } = useTranslation();
+ const [baseUrl, setBaseUrl] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
+ const editMode = props.user !== null;
+ const addButtonEnabled = (() => {
+ if (editMode) {
+ return username.length > 0 && password.length > 0;
+ }
+ const baseUrlValid = validUrl(baseUrl);
+ const baseUrlExists = props.users
+ ?.map((user) => user.baseUrl)
+ .includes(baseUrl);
return (
-
+ baseUrlValid &&
+ !baseUrlExists &&
+ username.length > 0 &&
+ password.length > 0
);
+ })();
+ const handleSubmit = async () => {
+ props.onSubmit({
+ baseUrl: baseUrl,
+ username: username,
+ password: password,
+ });
+ };
+ useEffect(() => {
+ if (editMode) {
+ setBaseUrl(props.user.baseUrl);
+ setUsername(props.user.username);
+ setPassword(props.user.password);
+ }
+ }, [editMode, props.user]);
+ return (
+
+ );
};
const Appearance = () => {
- const { t } = useTranslation();
- return (
-
-
- {t("prefs_appearance_title")}
-
-
-
-
-
- );
+ const { t } = useTranslation();
+ return (
+
+
+ {t("prefs_appearance_title")}
+
+
+
+
+
+ );
};
const Language = () => {
- const { t, i18n } = useTranslation();
- const labelId = "prefLanguage";
- const lang = i18n.resolvedLanguage ?? "en";
+ const { t, i18n } = useTranslation();
+ const labelId = "prefLanguage";
+ const lang = i18n.resolvedLanguage ?? "en";
- // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
- // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
- const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
- const showFlags = !navigator.userAgent.includes("Windows");
- let title = t("prefs_appearance_language_title");
- if (showFlags) {
- title += " " + randomFlags.join(" ");
- }
+ // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
+ // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
+ const randomFlags = shuffle([
+ "🇬🇧",
+ "🇺🇸",
+ "🇪🇸",
+ "🇫🇷",
+ "🇧🇬",
+ "🇨🇿",
+ "🇩🇪",
+ "🇵🇱",
+ "🇺🇦",
+ "🇨🇳",
+ "🇮🇹",
+ "🇭🇺",
+ "🇧🇷",
+ "🇳🇱",
+ "🇮🇩",
+ "🇯🇵",
+ "🇷🇺",
+ "🇹🇷",
+ ]).slice(0, 3);
+ const showFlags = !navigator.userAgent.includes("Windows");
+ let title = t("prefs_appearance_language_title");
+ if (showFlags) {
+ title += " " + randomFlags.join(" ");
+ }
- const handleChange = async (ev) => {
- await i18n.changeLanguage(ev.target.value);
- await maybeUpdateAccountSettings({
- language: ev.target.value
- });
- };
+ const handleChange = async (ev) => {
+ await i18n.changeLanguage(ev.target.value);
+ await maybeUpdateAccountSettings({
+ language: ev.target.value,
+ });
+ };
- // Remember: Flags are not languages. Don't put flags next to the language in the list.
- // Languages names from: https://www.omniglot.com/language/names.htm
- // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
+ // Remember: Flags are not languages. Don't put flags next to the language in the list.
+ // Languages names from: https://www.omniglot.com/language/names.htm
+ // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
- return (
-
-
-
-
-
- )
+ return (
+
+
+
+
+
+ );
};
const Reservations = () => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
+ const { t } = useTranslation();
+ const { account } = useContext(AccountContext);
+ const [dialogKey, setDialogKey] = useState(0);
+ const [dialogOpen, setDialogOpen] = useState(false);
- if (!config.enable_reservations || !session.exists() || !account) {
- return <>>;
- }
- const reservations = account.reservations || [];
- const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;
+ if (!config.enable_reservations || !session.exists() || !account) {
+ return <>>;
+ }
+ const reservations = account.reservations || [];
+ const limitReached =
+ account.role === Role.USER && account.stats.reservations_remaining === 0;
- const handleAddClick = () => {
- setDialogKey(prev => prev+1);
- setDialogOpen(true);
- };
+ const handleAddClick = () => {
+ setDialogKey((prev) => prev + 1);
+ setDialogOpen(true);
+ };
- return (
-
-
-
- {t("prefs_reservations_title")}
-
-
- {t("prefs_reservations_description")}
-
- {reservations.length > 0 && }
- {limitReached && {t("prefs_reservations_limit_reached")}}
-
-
-
- setDialogOpen(false)}
- />
-
-
- );
+ return (
+
+
+
+ {t("prefs_reservations_title")}
+
+ {t("prefs_reservations_description")}
+ {reservations.length > 0 && (
+
+ )}
+ {limitReached && (
+ {t("prefs_reservations_limit_reached")}
+ )}
+
+
+
+ setDialogOpen(false)}
+ />
+
+
+ );
};
const ReservationsTable = (props) => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogReservation, setDialogReservation] = useState(null);
- const [editDialogOpen, setEditDialogOpen] = useState(false);
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const { subscriptions } = useOutletContext();
- const localSubscriptions = (subscriptions?.length > 0)
- ? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s})))
- : {};
+ const { t } = useTranslation();
+ const [dialogKey, setDialogKey] = useState(0);
+ const [dialogReservation, setDialogReservation] = useState(null);
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const { subscriptions } = useOutletContext();
+ const localSubscriptions =
+ subscriptions?.length > 0
+ ? Object.assign(
+ {},
+ ...subscriptions
+ .filter((s) => s.baseUrl === config.base_url)
+ .map((s) => ({ [s.topic]: s }))
+ )
+ : {};
- const handleEditClick = (reservation) => {
- setDialogKey(prev => prev+1);
- setDialogReservation(reservation);
- setEditDialogOpen(true);
- };
+ const handleEditClick = (reservation) => {
+ setDialogKey((prev) => prev + 1);
+ setDialogReservation(reservation);
+ setEditDialogOpen(true);
+ };
- const handleDeleteClick = async (reservation) => {
- setDialogKey(prev => prev+1);
- setDialogReservation(reservation);
- setDeleteDialogOpen(true);
- };
+ const handleDeleteClick = async (reservation) => {
+ setDialogKey((prev) => prev + 1);
+ setDialogReservation(reservation);
+ setDeleteDialogOpen(true);
+ };
- const handleSubscribeClick = async (reservation) => {
- await subscribeTopic(config.base_url, reservation.topic);
- };
+ const handleSubscribeClick = async (reservation) => {
+ await subscribeTopic(config.base_url, reservation.topic);
+ };
- return (
-
-
-
- {t("prefs_reservations_table_topic_header")}
- {t("prefs_reservations_table_access_header")}
-
-
-
-
- {props.reservations.map(reservation => (
-
-
- {reservation.topic}
-
-
- {reservation.everyone === Permission.READ_WRITE &&
- <>
-
- {t("prefs_reservations_table_everyone_read_write")}
- >
- }
- {reservation.everyone === Permission.READ_ONLY &&
- <>
-
- {t("prefs_reservations_table_everyone_read_only")}
- >
- }
- {reservation.everyone === Permission.WRITE_ONLY &&
- <>
-
- {t("prefs_reservations_table_everyone_write_only")}
- >
- }
- {reservation.everyone === Permission.DENY_ALL &&
- <>
-
- {t("prefs_reservations_table_everyone_deny_all")}
- >
- }
-
-
- {!localSubscriptions[reservation.topic] &&
-
- } onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
-
- }
- handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
-
-
- handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
-
-
-
-
- ))}
-
- setEditDialogOpen(false)}
- />
- setDeleteDialogOpen(false)}
- />
-
- );
+ return (
+
+
+
+
+ {t("prefs_reservations_table_topic_header")}
+
+ {t("prefs_reservations_table_access_header")}
+
+
+
+
+ {props.reservations.map((reservation) => (
+
+
+ {reservation.topic}
+
+
+ {reservation.everyone === Permission.READ_WRITE && (
+ <>
+
+ {t("prefs_reservations_table_everyone_read_write")}
+ >
+ )}
+ {reservation.everyone === Permission.READ_ONLY && (
+ <>
+
+ {t("prefs_reservations_table_everyone_read_only")}
+ >
+ )}
+ {reservation.everyone === Permission.WRITE_ONLY && (
+ <>
+
+ {t("prefs_reservations_table_everyone_write_only")}
+ >
+ )}
+ {reservation.everyone === Permission.DENY_ALL && (
+ <>
+
+ {t("prefs_reservations_table_everyone_deny_all")}
+ >
+ )}
+
+
+ {!localSubscriptions[reservation.topic] && (
+
+ }
+ onClick={() => handleSubscribeClick(reservation)}
+ label={t("prefs_reservations_table_not_subscribed")}
+ color="primary"
+ variant="outlined"
+ />
+
+ )}
+ handleEditClick(reservation)}
+ aria-label={t("prefs_reservations_edit_button")}
+ >
+
+
+ handleDeleteClick(reservation)}
+ aria-label={t("prefs_reservations_delete_button")}
+ >
+
+
+
+
+ ))}
+
+ setEditDialogOpen(false)}
+ />
+ setDeleteDialogOpen(false)}
+ />
+
+ );
};
const maybeUpdateAccountSettings = async (payload) => {
- if (!session.exists()) {
- return;
- }
- try {
- await accountApi.updateSettings(payload);
- } catch (e) {
- console.log(`[Preferences] Error updating account settings`, e);
- if (e instanceof UnauthorizedError) {
- session.resetAndRedirect(routes.login);
- }
+ if (!session.exists()) {
+ return;
+ }
+ try {
+ await accountApi.updateSettings(payload);
+ } catch (e) {
+ console.log(`[Preferences] Error updating account settings`, e);
+ if (e instanceof UnauthorizedError) {
+ session.resetAndRedirect(routes.login);
}
+ }
};
export default Preferences;
diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js
index bfaccfc..e8825de 100644
--- a/web/src/components/PublishDialog.js
+++ b/web/src/components/PublishDialog.js
@@ -1,16 +1,16 @@
-import * as React from 'react';
-import {useContext, useEffect, useRef, useState} from 'react';
+import * as React from "react";
+import { useContext, useEffect, useRef, useState } from "react";
import theme from "./theme";
import {
- Checkbox,
- Chip,
- FormControl,
- FormControlLabel,
- InputLabel,
- Link,
- Select,
- Tooltip,
- useMediaQuery
+ Checkbox,
+ Chip,
+ FormControl,
+ FormControlLabel,
+ InputLabel,
+ Link,
+ Select,
+ Tooltip,
+ useMediaQuery,
} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
@@ -24,764 +24,972 @@ import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
-import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
-import {Close} from "@mui/icons-material";
+import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
+import { Close } from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
-import {formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
+import {
+ formatBytes,
+ maybeWithAuth,
+ topicShortUrl,
+ topicUrl,
+ validTopic,
+ validUrl,
+} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
-import {Trans, useTranslation} from "react-i18next";
+import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi from "../app/AccountApi";
-import {UnauthorizedError} from "../app/errors";
-import {AccountContext} from "./App";
+import { UnauthorizedError } from "../app/errors";
+import { AccountContext } from "./App";
const PublishDialog = (props) => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [baseUrl, setBaseUrl] = useState("");
- const [topic, setTopic] = useState("");
- const [message, setMessage] = useState("");
- const [messageFocused, setMessageFocused] = useState(true);
- const [title, setTitle] = useState("");
- const [tags, setTags] = useState("");
- const [priority, setPriority] = useState(3);
- const [clickUrl, setClickUrl] = useState("");
- const [attachUrl, setAttachUrl] = useState("");
- const [attachFile, setAttachFile] = useState(null);
- const [filename, setFilename] = useState("");
- const [filenameEdited, setFilenameEdited] = useState(false);
- const [email, setEmail] = useState("");
- const [call, setCall] = useState("");
- const [delay, setDelay] = useState("");
- const [publishAnother, setPublishAnother] = useState(false);
+ const { t } = useTranslation();
+ const { account } = useContext(AccountContext);
+ const [baseUrl, setBaseUrl] = useState("");
+ const [topic, setTopic] = useState("");
+ const [message, setMessage] = useState("");
+ const [messageFocused, setMessageFocused] = useState(true);
+ const [title, setTitle] = useState("");
+ const [tags, setTags] = useState("");
+ const [priority, setPriority] = useState(3);
+ const [clickUrl, setClickUrl] = useState("");
+ const [attachUrl, setAttachUrl] = useState("");
+ const [attachFile, setAttachFile] = useState(null);
+ const [filename, setFilename] = useState("");
+ const [filenameEdited, setFilenameEdited] = useState(false);
+ const [email, setEmail] = useState("");
+ const [call, setCall] = useState("");
+ const [delay, setDelay] = useState("");
+ const [publishAnother, setPublishAnother] = useState(false);
- const [showTopicUrl, setShowTopicUrl] = useState("");
- const [showClickUrl, setShowClickUrl] = useState(false);
- const [showAttachUrl, setShowAttachUrl] = useState(false);
- const [showEmail, setShowEmail] = useState(false);
- const [showCall, setShowCall] = useState(false);
- const [showDelay, setShowDelay] = useState(false);
+ const [showTopicUrl, setShowTopicUrl] = useState("");
+ const [showClickUrl, setShowClickUrl] = useState(false);
+ const [showAttachUrl, setShowAttachUrl] = useState(false);
+ const [showEmail, setShowEmail] = useState(false);
+ const [showCall, setShowCall] = useState(false);
+ const [showDelay, setShowDelay] = useState(false);
- const showAttachFile = !!attachFile && !showAttachUrl;
- const attachFileInput = useRef();
- const [attachFileError, setAttachFileError] = useState("");
+ const showAttachFile = !!attachFile && !showAttachUrl;
+ const attachFileInput = useRef();
+ const [attachFileError, setAttachFileError] = useState("");
- const [activeRequest, setActiveRequest] = useState(null);
- const [status, setStatus] = useState("");
- const disabled = !!activeRequest;
+ const [activeRequest, setActiveRequest] = useState(null);
+ const [status, setStatus] = useState("");
+ const disabled = !!activeRequest;
- const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
+ const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
- const [dropZone, setDropZone] = useState(false);
- const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
+ const [dropZone, setDropZone] = useState(false);
+ const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
- const open = !!props.openMode;
- const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+ const open = !!props.openMode;
+ const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- useEffect(() => {
- window.addEventListener('dragenter', () => {
- props.onDragEnter();
- setDropZone(true);
- });
- }, []);
+ useEffect(() => {
+ window.addEventListener("dragenter", () => {
+ props.onDragEnter();
+ setDropZone(true);
+ });
+ }, []);
- useEffect(() => {
- setBaseUrl(props.baseUrl);
- setTopic(props.topic);
- setShowTopicUrl(!props.baseUrl || !props.topic);
- setMessageFocused(!!props.topic); // Focus message only if topic is set
- }, [props.baseUrl, props.topic]);
+ useEffect(() => {
+ setBaseUrl(props.baseUrl);
+ setTopic(props.topic);
+ setShowTopicUrl(!props.baseUrl || !props.topic);
+ setMessageFocused(!!props.topic); // Focus message only if topic is set
+ }, [props.baseUrl, props.topic]);
- useEffect(() => {
- const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
- setSendButtonEnabled(valid);
- }, [baseUrl, topic, attachFileError]);
+ useEffect(() => {
+ const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
+ setSendButtonEnabled(valid);
+ }, [baseUrl, topic, attachFileError]);
- useEffect(() => {
- setMessage(props.message);
- }, [props.message]);
+ useEffect(() => {
+ setMessage(props.message);
+ }, [props.message]);
- const updateBaseUrl = (newVal) => {
- if (validUrl(newVal)) {
- setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?://
+ const updateBaseUrl = (newVal) => {
+ if (validUrl(newVal)) {
+ setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
+ } else {
+ setBaseUrl(newVal);
+ }
+ };
+
+ const handleSubmit = async () => {
+ const url = new URL(topicUrl(baseUrl, topic));
+ if (title.trim()) {
+ url.searchParams.append("title", title.trim());
+ }
+ if (tags.trim()) {
+ url.searchParams.append("tags", tags.trim());
+ }
+ if (priority && priority !== 3) {
+ url.searchParams.append("priority", priority.toString());
+ }
+ if (clickUrl.trim()) {
+ url.searchParams.append("click", clickUrl.trim());
+ }
+ if (attachUrl.trim()) {
+ url.searchParams.append("attach", attachUrl.trim());
+ }
+ if (filename.trim()) {
+ url.searchParams.append("filename", filename.trim());
+ }
+ if (email.trim()) {
+ url.searchParams.append("email", email.trim());
+ }
+ if (call.trim()) {
+ url.searchParams.append("call", call.trim());
+ }
+ if (delay.trim()) {
+ url.searchParams.append("delay", delay.trim());
+ }
+ if (attachFile && message.trim()) {
+ url.searchParams.append(
+ "message",
+ message.replaceAll("\n", "\\n").trim()
+ );
+ }
+ const body = attachFile ? attachFile : message;
+ try {
+ const user = await userManager.get(baseUrl);
+ const headers = maybeWithAuth({}, user);
+ const progressFn = (ev) => {
+ if (ev.loaded > 0 && ev.total > 0) {
+ setStatus(
+ t("publish_dialog_progress_uploading_detail", {
+ loaded: formatBytes(ev.loaded),
+ total: formatBytes(ev.total),
+ percent: Math.round((ev.loaded * 100.0) / ev.total),
+ })
+ );
} else {
- setBaseUrl(newVal);
+ setStatus(t("publish_dialog_progress_uploading"));
}
- };
+ };
+ const request = api.publishXHR(url, body, headers, progressFn);
+ setActiveRequest(request);
+ await request;
+ if (!publishAnother) {
+ props.onClose();
+ } else {
+ setStatus(t("publish_dialog_message_published"));
+ setActiveRequest(null);
+ }
+ } catch (e) {
+ setStatus(
+
+ {e}
+
+ );
+ setActiveRequest(null);
+ }
+ };
- const handleSubmit = async () => {
- const url = new URL(topicUrl(baseUrl, topic));
- if (title.trim()) {
- url.searchParams.append("title", title.trim());
- }
- if (tags.trim()) {
- url.searchParams.append("tags", tags.trim());
- }
- if (priority && priority !== 3) {
- url.searchParams.append("priority", priority.toString());
- }
- if (clickUrl.trim()) {
- url.searchParams.append("click", clickUrl.trim());
- }
- if (attachUrl.trim()) {
- url.searchParams.append("attach", attachUrl.trim());
- }
- if (filename.trim()) {
- url.searchParams.append("filename", filename.trim());
- }
- if (email.trim()) {
- url.searchParams.append("email", email.trim());
- }
- if (call.trim()) {
- url.searchParams.append("call", call.trim());
- }
- if (delay.trim()) {
- url.searchParams.append("delay", delay.trim());
- }
- if (attachFile && message.trim()) {
- url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
- }
- const body = (attachFile) ? attachFile : message;
- try {
- const user = await userManager.get(baseUrl);
- const headers = maybeWithAuth({}, user);
- const progressFn = (ev) => {
- if (ev.loaded > 0 && ev.total > 0) {
- setStatus(t("publish_dialog_progress_uploading_detail", {
- loaded: formatBytes(ev.loaded),
- total: formatBytes(ev.total),
- percent: Math.round(ev.loaded * 100.0 / ev.total)
- }));
- } else {
- setStatus(t("publish_dialog_progress_uploading"));
+ const checkAttachmentLimits = async (file) => {
+ try {
+ const account = await accountApi.get();
+ const fileSizeLimit = account.limits.attachment_file_size ?? 0;
+ const remainingBytes = account.stats.attachment_total_size_remaining;
+ const fileSizeLimitReached =
+ fileSizeLimit > 0 && file.size > fileSizeLimit;
+ const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
+ if (fileSizeLimitReached && quotaReached) {
+ return setAttachFileError(
+ t("publish_dialog_attachment_limits_file_and_quota_reached", {
+ fileSizeLimit: formatBytes(fileSizeLimit),
+ remainingBytes: formatBytes(remainingBytes),
+ })
+ );
+ } else if (fileSizeLimitReached) {
+ return setAttachFileError(
+ t("publish_dialog_attachment_limits_file_reached", {
+ fileSizeLimit: formatBytes(fileSizeLimit),
+ })
+ );
+ } else if (quotaReached) {
+ return setAttachFileError(
+ t("publish_dialog_attachment_limits_quota_reached", {
+ remainingBytes: formatBytes(remainingBytes),
+ })
+ );
+ }
+ setAttachFileError("");
+ } catch (e) {
+ console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
+ if (e instanceof UnauthorizedError) {
+ session.resetAndRedirect(routes.login);
+ } else {
+ setAttachFileError(""); // Reset error (rely on server-side checking)
+ }
+ }
+ };
+
+ const handleAttachFileClick = () => {
+ attachFileInput.current.click();
+ };
+
+ const handleAttachFileChanged = async (ev) => {
+ await updateAttachFile(ev.target.files[0]);
+ };
+
+ const handleAttachFileDrop = async (ev) => {
+ ev.preventDefault();
+ setDropZone(false);
+ await updateAttachFile(ev.dataTransfer.files[0]);
+ };
+
+ const updateAttachFile = async (file) => {
+ setAttachFile(file);
+ setFilename(file.name);
+ props.onResetOpenMode();
+ await checkAttachmentLimits(file);
+ };
+
+ const handleAttachFileDragLeave = () => {
+ setDropZone(false);
+ if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
+ props.onClose(); // Only close dialog if it was not open before dragging file in
+ }
+ };
+
+ const handleEmojiClick = (ev) => {
+ setEmojiPickerAnchorEl(ev.currentTarget);
+ };
+
+ const handleEmojiPick = (emoji) => {
+ setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
+ };
+
+ const handleEmojiClose = () => {
+ setEmojiPickerAnchorEl(null);
+ };
+
+ const priorities = {
+ 1: { label: t("publish_dialog_priority_min"), file: priority1 },
+ 2: { label: t("publish_dialog_priority_low"), file: priority2 },
+ 3: { label: t("publish_dialog_priority_default"), file: priority3 },
+ 4: { label: t("publish_dialog_priority_high"), file: priority4 },
+ 5: { label: t("publish_dialog_priority_max"), file: priority5 },
+ };
+
+ return (
+ <>
+ {dropZone && (
+
+ )}
+
+ >
+ );
};
const Row = (props) => {
- return (
-
- {props.children}
-
- );
+ return (
+
+ {props.children}
+
+ );
};
const ClosableRow = (props) => {
- const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
- return (
-
- {props.children}
- {closable &&
-
-
-
- }
-
- );
+ const closable = props.hasOwnProperty("closable") ? props.closable : true;
+ return (
+
+ {props.children}
+ {closable && (
+
+
+
+ )}
+
+ );
};
const DialogIconButton = (props) => {
- const sx = props.sx || {};
- return (
-
- {props.children}
-
- );
+ const sx = props.sx || {};
+ return (
+
+ {props.children}
+
+ );
};
const AttachmentBox = (props) => {
- const { t } = useTranslation();
- const file = props.file;
- return (
- <>
-
- {t("publish_dialog_attached_file_title")}
-
-
-
-
- props.onChangeFilename(ev.target.value)}
- disabled={props.disabled}
- />
-
-
- {formatBytes(file.size)}
- {props.error &&
-
- {" "}({props.error})
-
- }
-
-
-
-
-
-
- >
- );
+ const { t } = useTranslation();
+ const file = props.file;
+ return (
+ <>
+
+ {t("publish_dialog_attached_file_title")}
+
+
+
+
+ props.onChangeFilename(ev.target.value)}
+ disabled={props.disabled}
+ />
+
+
+ {formatBytes(file.size)}
+ {props.error && (
+
+ {" "}
+ ({props.error})
+
+ )}
+
+
+
+
+
+
+ >
+ );
};
const ExpandingTextField = (props) => {
- const invisibleFieldRef = useRef();
- const [textWidth, setTextWidth] = useState(props.minWidth);
- const determineTextWidth = () => {
- const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
- if (!boundingRect) {
- return props.minWidth;
- }
- return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
- };
- useEffect(() => {
- setTextWidth(determineTextWidth() + 5);
- }, [props.value]);
- return (
- <>
-
- {props.value}
-
-
- >
- )
+ const invisibleFieldRef = useRef();
+ const [textWidth, setTextWidth] = useState(props.minWidth);
+ const determineTextWidth = () => {
+ const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
+ if (!boundingRect) {
+ return props.minWidth;
+ }
+ return boundingRect.width >= props.minWidth
+ ? Math.round(boundingRect.width)
+ : props.minWidth;
+ };
+ useEffect(() => {
+ setTextWidth(determineTextWidth() + 5);
+ }, [props.value]);
+ return (
+ <>
+
+ {props.value}
+
+
+ >
+ );
};
const DropArea = (props) => {
- const allowDrag = (ev) => {
- // This is where we could disallow certain files to be dragged in.
- // For now we allow all files.
+ const allowDrag = (ev) => {
+ // This is where we could disallow certain files to be dragged in.
+ // For now we allow all files.
- ev.dataTransfer.dropEffect = 'copy';
- ev.preventDefault();
- };
+ ev.dataTransfer.dropEffect = "copy";
+ ev.preventDefault();
+ };
- return (
-
- );
+ return (
+
+ );
};
const DropBox = () => {
- const { t } = useTranslation();
- return (
-
-
- {t("publish_dialog_drop_file_here")}
-
-
- );
-}
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("publish_dialog_drop_file_here")}
+
+
+
+ );
+};
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";
diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js
index e466269..f36ea6c 100644
--- a/web/src/components/ReserveDialogs.js
+++ b/web/src/components/ReserveDialogs.js
@@ -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 (
-
- {t("prefs_reservations_dialog_title_add")}
-
-
- {t("prefs_reservations_dialog_description")}
-
- {allowTopicEdit && setTopic(ev.target.value)}
- type="url"
- fullWidth
- variant="standard"
- />}
-
-
-
-
-
-
-
- );
+ return (
+
+ {t("prefs_reservations_dialog_title_add")}
+
+
+ {t("prefs_reservations_dialog_description")}
+
+ {allowTopicEdit && (
+ setTopic(ev.target.value)}
+ type="url"
+ fullWidth
+ variant="standard"
+ />
+ )}
+
+
+
+
+
+
+
+ );
};
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 (
-
- {t("prefs_reservations_dialog_title_edit")}
-
-
- {t("prefs_reservations_dialog_description")}
-
-
-
-
-
-
-
-
- );
+ return (
+
+ {t("prefs_reservations_dialog_title_edit")}
+
+
+ {t("prefs_reservations_dialog_description")}
+
+
+
+
+
+
+
+
+ );
};
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 (
-
- {t("prefs_reservations_dialog_title_delete")}
-
-
- {t("reservation_delete_dialog_description")}
-
-
-
-
- {!deleteMessages &&
-
- {t("reservation_delete_dialog_action_keep_description")}
-
- }
- {deleteMessages &&
-
- {t("reservation_delete_dialog_action_delete_description")}
-
- }
-
-
-
-
-
-
- );
+ return (
+
+ {t("prefs_reservations_dialog_title_delete")}
+
+
+ {t("reservation_delete_dialog_description")}
+
+
+
+
+ {!deleteMessages && (
+
+ {t("reservation_delete_dialog_action_keep_description")}
+
+ )}
+ {deleteMessages && (
+
+ {t("reservation_delete_dialog_action_delete_description")}
+
+ )}
+
+
+
+
+
+
+ );
};
-
diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js
index 0d7b05b..3b22df3 100644
--- a/web/src/components/ReserveIcons.js
+++ b/web/src/components/ReserveIcons.js
@@ -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
;
+ return
;
});
export const PermissionDenyAll = React.forwardRef((props, ref) => {
- return
;
+ return
;
});
export const PermissionRead = React.forwardRef((props, ref) => {
- return
;
+ return
;
});
export const PermissionWrite = React.forwardRef((props, ref) => {
- return
;
+ return
;
});
const PermissionInternal = React.forwardRef((props, ref) => {
- const size = props.size ?? "medium";
- const Icon = props.icon;
- return (
-
-
- {props.text &&
-
- {props.text}
-
- }
+ const size = props.size ?? "medium";
+ const Icon = props.icon;
+ return (
+
+
+ {props.text && (
+
+ {props.text}
- );
+ )}
+
+ );
});
diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js
index e5daf69..76113ba 100644
--- a/web/src/components/ReserveTopicSelect.js
+++ b/web/src/components/ReserveTopicSelect.js
@@ -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 (
-
-
-
- );
+ const { t } = useTranslation();
+ const sx = props.sx || {};
+ return (
+
+
+
+ );
};
export default ReserveTopicSelect;
diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js
index 856ce8f..39409a5 100644
--- a/web/src/components/Signup.js
+++ b/web/src/components/Signup.js
@@ -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 (
-
- {t("signup_disabled")}
-
- );
+ 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 (
-
-
- {t("signup_title")}
-
-
- setUsername(ev.target.value.trim())}
- autoFocus
- />
- setPassword(ev.target.value.trim())}
- InputProps={{
- endAdornment: (
-
- setShowPassword(!showPassword)}
- onMouseDown={(ev) => ev.preventDefault()}
- edge="end"
- >
- {showPassword ? : }
-
-
- )
- }}
- />
- setConfirm(ev.target.value.trim())}
- InputProps={{
- endAdornment: (
-
- setShowConfirm(!showConfirm)}
- onMouseDown={(ev) => ev.preventDefault()}
- edge="end"
- >
- {showConfirm ? : }
-
-
- )
- }}
- />
-
- {error &&
-
-
- {error}
-
- }
-
- {config.enable_login &&
-
-
- {t("signup_already_have_account")}
-
-
- }
-
+
+
+ {t("signup_disabled")}
+
+
);
-}
+ }
+
+ return (
+
+ {t("signup_title")}
+
+ setUsername(ev.target.value.trim())}
+ autoFocus
+ />
+ setPassword(ev.target.value.trim())}
+ InputProps={{
+ endAdornment: (
+
+ setShowPassword(!showPassword)}
+ onMouseDown={(ev) => ev.preventDefault()}
+ edge="end"
+ >
+ {showPassword ? : }
+
+
+ ),
+ }}
+ />
+ setConfirm(ev.target.value.trim())}
+ InputProps={{
+ endAdornment: (
+
+ setShowConfirm(!showConfirm)}
+ onMouseDown={(ev) => ev.preventDefault()}
+ edge="end"
+ >
+ {showConfirm ? : }
+
+
+ ),
+ }}
+ />
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {config.enable_login && (
+
+
+ {t("signup_already_have_account")}
+
+
+ )}
+
+ );
+};
export default Signup;
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 95f1c47..940eafe 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -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 (
-
- {!showLoginPage && setShowLoginPage(true)}
- onSuccess={handleSuccess}
- />}
- {showLoginPage && setShowLoginPage(false)}
- onSuccess={handleSuccess}
- />}
-
- );
+ return (
+
+ {!showLoginPage && (
+ setShowLoginPage(true)}
+ onSuccess={handleSuccess}
+ />
+ )}
+ {showLoginPage && (
+ setShowLoginPage(false)}
+ onSuccess={handleSuccess}
+ />
+ )}
+
+ );
};
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 (
- <>
- {t("subscribe_dialog_subscribe_title")}
-
-
- {t("subscribe_dialog_subscribe_description")}
-
-
- props.setTopic(ev.target.value)}
- type="text"
- fullWidth
- variant="standard"
- inputProps={{
- maxLength: 64,
- "aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
- }}
- />
-
-
- {showReserveTopicCheckbox &&
-
- setReserveTopicVisible(ev.target.checked)}
- inputProps={{
- "aria-label": t("reserve_dialog_checkbox_label")
- }}
- />
- }
- label={
- <>
- {t("reserve_dialog_checkbox_label")}
-
- >
- }
- />
- {reserveTopicVisible &&
-
- }
-
- }
- {!reserveTopicVisible &&
-
-
- }
- label={t("subscribe_dialog_subscribe_use_another_label")}/>
- {anotherServerVisible &&
-
- }
- />}
-
- }
-
-
-
-
-
- >
+ 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 (
+ <>
+ {t("subscribe_dialog_subscribe_title")}
+
+
+ {t("subscribe_dialog_subscribe_description")}
+
+
+ props.setTopic(ev.target.value)}
+ type="text"
+ fullWidth
+ variant="standard"
+ inputProps={{
+ maxLength: 64,
+ "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
+ }}
+ />
+
+
+ {showReserveTopicCheckbox && (
+
+ setReserveTopicVisible(ev.target.checked)}
+ inputProps={{
+ "aria-label": t("reserve_dialog_checkbox_label"),
+ }}
+ />
+ }
+ label={
+ <>
+ {t("reserve_dialog_checkbox_label")}
+
+ >
+ }
+ />
+ {reserveTopicVisible && (
+
+ )}
+
+ )}
+ {!reserveTopicVisible && (
+
+
+ }
+ label={t("subscribe_dialog_subscribe_use_another_label")}
+ />
+ {anotherServerVisible && (
+ (
+
+ )}
+ />
+ )}
+
+ )}
+
+
+
+
+
+ >
+ );
};
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 (
- <>
- {t("subscribe_dialog_login_title")}
-
-
- {t("subscribe_dialog_login_description")}
-
- setUsername(ev.target.value)}
- type="text"
- fullWidth
- variant="standard"
- inputProps={{
- "aria-label": t("subscribe_dialog_login_username_label")
- }}
- />
- setPassword(ev.target.value)}
- fullWidth
- variant="standard"
- inputProps={{
- "aria-label": t("subscribe_dialog_login_password_label")
- }}
- />
-
-
-
-
-
- >
+ 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 (
+ <>
+ {t("subscribe_dialog_login_title")}
+
+
+ {t("subscribe_dialog_login_description")}
+
+ setUsername(ev.target.value)}
+ type="text"
+ fullWidth
+ variant="standard"
+ inputProps={{
+ "aria-label": t("subscribe_dialog_login_username_label"),
+ }}
+ />
+ setPassword(ev.target.value)}
+ fullWidth
+ variant="standard"
+ inputProps={{
+ "aria-label": t("subscribe_dialog_login_password_label"),
+ }}
+ />
+
+
+
+
+
+ >
+ );
};
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;
diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js
index 024b6f2..eb575dc 100644
--- a/web/src/components/SubscriptionPopup.js
+++ b/web/src/components/SubscriptionPopup.js
@@ -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 (
- <>
-
-
- {showReservationAdd && }
- {showReservationAddDisabled &&
-
- }
- {showReservationEdit && }
- {showReservationDelete && }
-
-
-
-
-
- setShowPublishError(false)}
- message={t("message_bar_error_publishing")}
- />
- setDisplayNameDialogOpen(false)}
- />
- {showReservationAdd &&
- setReserveAddDialogOpen(false)}
- />
- }
- {showReservationEdit &&
- setReserveEditDialogOpen(false)}
- />
- }
- {showReservationDelete &&
- setReserveDeleteDialogOpen(false)}
- />
- }
-
- >
+ 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 (
+ <>
+
+
+ {showReservationAdd && (
+
+ )}
+ {showReservationAddDisabled && (
+
+ )}
+ {showReservationEdit && (
+
+ )}
+ {showReservationDelete && (
+
+ )}
+
+
+
+
+
+ setShowPublishError(false)}
+ message={t("message_bar_error_publishing")}
+ />
+ setDisplayNameDialogOpen(false)}
+ />
+ {showReservationAdd && (
+ setReserveAddDialogOpen(false)}
+ />
+ )}
+ {showReservationEdit && (
+ setReserveEditDialogOpen(false)}
+ />
+ )}
+ {showReservationDelete && (
+ setReserveDeleteDialogOpen(false)}
+ />
+ )}
+
+ >
+ );
};
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 (
-
- {t("display_name_dialog_title")}
-
-
- {t("display_name_dialog_description")}
-
- setDisplayName(ev.target.value)}
- type="text"
- fullWidth
- variant="standard"
- inputProps={{
- maxLength: 64,
- "aria-label": t("display_name_dialog_placeholder")
- }}
- InputProps={{
- endAdornment: (
-
- setDisplayName("")} edge="end">
-
-
-
- )
- }}
- />
-
-
-
-
-
-
- );
+ return (
+
+ {t("display_name_dialog_title")}
+
+
+ {t("display_name_dialog_description")}
+
+ setDisplayName(ev.target.value)}
+ type="text"
+ fullWidth
+ variant="standard"
+ inputProps={{
+ maxLength: 64,
+ "aria-label": t("display_name_dialog_placeholder"),
+ }}
+ InputProps={{
+ endAdornment: (
+
+ setDisplayName("")} edge="end">
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+ );
};
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) ? : ;
- } else if (account) {
- return ;
- }
+ 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 ? (
+
+ ) : (
+
+ );
+ } else if (account) {
+ return ;
+ }
+ return <>>;
};
const LimitReachedChip = () => {
- const { t } = useTranslation();
- return (
-
- );
+ const { t } = useTranslation();
+ return (
+
+ );
};
export const ProChip = () => {
- const { t } = useTranslation();
- return (
-
- );
+ const { t } = useTranslation();
+ return (
+
+ );
};
-
-
diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js
index 0b91b1b..94b878c 100644
--- a/web/src/components/UpgradeDialog.js
+++ b/web/src/components/UpgradeDialog.js
@@ -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 (
-
-
-
-
{t("account_upgrade_dialog_title")}
-
- {t("account_upgrade_dialog_interval_monthly")}
- setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
- />
- {t("account_upgrade_dialog_interval_yearly")}
- {discount > 0 &&
-
- }
-
-
-
-
-
- {tiers.map(tier =>
- setNewTierCode(tier.code)} // tier.code may be undefined!
- />
- )}
-
- {banner === Banner.CANCEL_WARNING &&
-
-
-
- }
- {banner === Banner.PRORATION_INFO &&
-
-
-
- }
- {banner === Banner.RESERVATIONS_WARNING &&
-
- ,
- }}
- />
-
- }
-
-
-
- {config.billing_contact.indexOf('@') !== -1 &&
- <> }}/>{" "}>
- }
- {config.billing_contact.match(`^http?s://`) &&
- <> }}/>{" "}>
- }
- {error}
-
-
-
-
-
-
-
+ // 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 (
+
+
+
+
{t("account_upgrade_dialog_title")}
+
+
+ {t("account_upgrade_dialog_interval_monthly")}
+
+
+ setInterval(
+ ev.target.checked
+ ? SubscriptionInterval.YEAR
+ : SubscriptionInterval.MONTH
+ )
+ }
+ />
+
+ {t("account_upgrade_dialog_interval_yearly")}
+
+ {discount > 0 && (
+
+ )}
+
+
+
+
+
+ {tiers.map((tier) => (
+ setNewTierCode(tier.code)} // tier.code may be undefined!
+ />
+ ))}
+
+ {banner === Banner.CANCEL_WARNING && (
+
+
+
+ )}
+ {banner === Banner.PRORATION_INFO && (
+
+
+
+ )}
+ {banner === Banner.RESERVATIONS_WARNING && (
+
+ ,
+ }}
+ />
+
+ )}
+
+
+
+ {config.billing_contact.indexOf("@") !== -1 && (
+ <>
+ ,
+ }}
+ />{" "}
+ >
+ )}
+ {config.billing_contact.match(`^http?s://`) && (
+ <>
+ ,
+ }}
+ />{" "}
+ >
+ )}
+ {error}
+
+
+
+
+
+
+
+ );
};
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 (
-
-
-
-
- {labelStyle &&
- {labelText}
- }
-
- {tier.name || t("account_basics_tier_free")}
-
-
- {formatPrice(monthlyPrice)}
- {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}>}
-
-
- {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}}
- {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}
- {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}
- {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}}
- {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}
- {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}}
- {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}}
-
- {tier.prices && props.interval === SubscriptionInterval.MONTH &&
-
- {t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })}
-
- }
- {tier.prices && props.interval === SubscriptionInterval.YEAR &&
-
- {t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })}
-
- }
-
-
-
-
-
- );
-}
+ return (
+
+
+
+
+ {labelStyle && (
+
+ {labelText}
+
+ )}
+
+ {tier.name || t("account_basics_tier_free")}
+
+
+
+ {formatPrice(monthlyPrice)}
+
+ {monthlyPrice > 0 && (
+ <>/ {t("account_upgrade_dialog_tier_price_per_month")}>
+ )}
+
+
+ {tier.limits.reservations > 0 && (
+
+ {t("account_upgrade_dialog_tier_features_reservations", {
+ reservations: tier.limits.reservations,
+ count: tier.limits.reservations,
+ })}
+
+ )}
+
+ {t("account_upgrade_dialog_tier_features_messages", {
+ messages: formatNumber(tier.limits.messages),
+ count: tier.limits.messages,
+ })}
+
+
+ {t("account_upgrade_dialog_tier_features_emails", {
+ emails: formatNumber(tier.limits.emails),
+ count: tier.limits.emails,
+ })}
+
+ {tier.limits.calls > 0 && (
+
+ {t("account_upgrade_dialog_tier_features_calls", {
+ calls: formatNumber(tier.limits.calls),
+ count: tier.limits.calls,
+ })}
+
+ )}
+
+ {t(
+ "account_upgrade_dialog_tier_features_attachment_file_size",
+ { filesize: formatBytes(tier.limits.attachment_file_size, 0) }
+ )}
+
+ {tier.limits.reservations === 0 && (
+
+ {t("account_upgrade_dialog_tier_features_no_reservations")}
+
+ )}
+ {tier.limits.calls === 0 && (
+
+ {t("account_upgrade_dialog_tier_features_no_calls")}
+
+ )}
+
+ {tier.prices && props.interval === SubscriptionInterval.MONTH && (
+
+ {t("account_upgrade_dialog_tier_price_billed_monthly", {
+ price: formatPrice(tier.prices.month * 12),
+ })}
+
+ )}
+ {tier.prices && props.interval === SubscriptionInterval.YEAR && (
+
+ {t("account_upgrade_dialog_tier_price_billed_yearly", {
+ price: formatPrice(tier.prices.year),
+ save: formatPrice(tier.prices.month * 12 - tier.prices.year),
+ })}
+
+ )}
+
+
+
+
+ );
+};
const Feature = (props) => {
- return {props.children};
-}
+ return {props.children};
+};
const NoFeature = (props) => {
- return {props.children};
-}
+ return {props.children};
+};
const FeatureItem = (props) => {
- return (
-
-
- {props.feature && }
- {!props.feature && }
-
-
- {props.children}
-
- }
- />
-
-
- );
+ return (
+
+
+ {props.feature && }
+ {!props.feature && }
+
+ {props.children}}
+ />
+
+ );
};
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;
diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js
index b1ce8ff..0fc0204 100644
--- a/web/src/components/hooks.js
+++ b/web/src/components/hooks.js
@@ -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();
+ };
+ }, []);
+};
diff --git a/web/src/components/i18n.js b/web/src/components/i18n.js
index 42eb572..2bc315c 100644
--- a/web/src/components/i18n.js
+++ b/web/src/components/i18n.js
@@ -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;
diff --git a/web/src/components/routes.js b/web/src/components/routes.js
index d1db160..17e0eac 100644
--- a/web/src/components/routes.js
+++ b/web/src/components/routes.js
@@ -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;
diff --git a/web/src/components/styles.js b/web/src/components/styles.js
index d612794..6f1e30b 100644
--- a/web/src/components/styles.js
+++ b/web/src/components/styles.js
@@ -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
});
diff --git a/web/src/components/theme.js b/web/src/components/theme.js
index 3fdafae..ca77cdc 100644
--- a/web/src/components/theme.js
+++ b/web/src/components/theme.js
@@ -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",
+ },
+ },
+ },
+ },
},
});
diff --git a/web/src/index.js b/web/src/index.js
index 659bcb8..d60c05a 100644
--- a/web/src/index.js
+++ b/web/src/index.js
@@ -1,6 +1,6 @@
-import * as React from 'react';
-import { createRoot } from 'react-dom/client';
-import App from './components/App';
+import * as React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./components/App";
-const root = createRoot(document.querySelector('#root'));
+const root = createRoot(document.querySelector("#root"));
root.render();