diff --git a/server/server_account.go b/server/server_account.go
index 1fcfabe..432b699 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -447,6 +447,12 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
return err
}
+ deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages")
+ if deleteMessages {
+ if err := s.messageCache.ExpireMessages(topic); err != nil {
+ return err
+ }
+ }
return s.writeJSON(w, newSuccessResponse())
}
diff --git a/web/public/config.js b/web/public/config.js
index 61f5ca0..3113ff4 100644
--- a/web/public/config.js
+++ b/web/public/config.js
@@ -6,7 +6,7 @@
// During web development, you may change values here for rapid testing.
var config = {
- base_url: "https://127-0-0-1.my.local-ip.co", // window.location.origin FIXME update before merging
+ base_url: "https://127.0.0.1", // window.location.origin FIXME update before merging
app_root: "/app",
enable_login: true,
enable_signup: true,
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index 652282b..b897609 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -1,5 +1,6 @@
{
"common_cancel": "Cancel",
+ "common_save": "Save",
"signup_title": "Create a ntfy account",
"signup_form_username": "Username",
"signup_form_password": "Password",
@@ -18,7 +19,10 @@
"action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings",
"action_bar_account": "Account",
- "action_bar_subscription_settings": "Subscription settings",
+ "action_bar_change_display_name": "Change display name",
+ "action_bar_reservation_add": "Reserve topic",
+ "action_bar_reservation_edit": "Change reservation",
+ "action_bar_reservation_delete": "Remove reservation",
"action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe",
@@ -82,12 +86,10 @@
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
"notifications_example": "Example",
"notifications_more_details": "For more information, check out the website or documentation.",
- "subscription_settings_dialog_title": "Subscription settings",
- "subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
- "subscription_settings_dialog_display_name_placeholder": "Display name",
- "subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
- "subscription_settings_button_cancel": "Cancel",
- "subscription_settings_button_save": "Save",
+ "display_name_dialog_title": "Change display name",
+ "display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
+ "display_name_dialog_placeholder": "Display name",
+ "reserve_dialog_checkbox_label": "Reserve topic and configure access",
"notifications_loading": "Loading notifications …",
"publish_dialog_title_topic": "Publish to {{topic}}",
"publish_dialog_title_no_topic": "Publish notification",
@@ -309,11 +311,19 @@
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
"prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish",
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
+ "prefs_reservations_table_not_subscribed": "Not subscribed",
"prefs_reservations_dialog_title_add": "Reserve topic",
"prefs_reservations_dialog_title_edit": "Edit reserved topic",
+ "prefs_reservations_dialog_title_delete": "Delete topic reservation",
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_dialog_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access",
+ "reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
+ "reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
+ "reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",
+ "reservation_delete_dialog_action_delete_title": "Delete cached messages and attachments",
+ "reservation_delete_dialog_action_delete_description": "Cached messages and attachments will be permanently deleted. This action cannot be undone.",
+ "reservation_delete_dialog_submit_button": "Delete reservation",
"priority_min": "min",
"priority_low": "low",
"priority_default": "default",
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js
index 31df0a8..512fd1b 100644
--- a/web/src/app/AccountApi.js
+++ b/web/src/app/AccountApi.js
@@ -308,12 +308,15 @@ class AccountApi {
}
}
- async deleteReservation(topic) {
+ async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`);
+ const headers = {
+ "X-Delete-Messages": deleteMessages ? "true" : "false"
+ }
const response = await fetch(url, {
method: "DELETE",
- headers: withBearerAuth({}, session.token())
+ headers: withBearerAuth(headers, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js
index 4ad4068..e58f150 100644
--- a/web/src/components/ActionBar.js
+++ b/web/src/components/ActionBar.js
@@ -7,28 +7,26 @@ import Typography from "@mui/material/Typography";
import * as React from "react";
import {useState} from "react";
import Box from "@mui/material/Box";
-import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils";
+import {topicDisplayName} from "../app/utils";
import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom";
import MenuItem from '@mui/material/MenuItem';
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
-import api from "../app/Api";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
-import {Portal, Snackbar} from "@mui/material";
-import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import {Logout, Person, Settings} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
-import accountApi, {UnauthorizedError} from "../app/AccountApi";
+import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
+import SubscriptionPopup from "./SubscriptionPopup";
const ActionBar = (props) => {
const { t } = useTranslation();
@@ -86,133 +84,28 @@ const ActionBar = (props) => {
const SettingsIcons = (props) => {
const { t } = useTranslation();
- const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null);
- const [snackOpen, setSnackOpen] = useState(false);
- const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
const subscription = props.subscription;
- const open = Boolean(anchorEl);
-
- const handleToggleOpen = (event) => {
- setAnchorEl(event.currentTarget);
- };
const handleToggleMute = async () => {
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
}
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- const handleClearAll = async (event) => {
- console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
- await subscriptionManager.deleteNotifications(props.subscription.id);
- };
-
- const handleUnsubscribe = async (event) => {
- console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription);
- await subscriptionManager.remove(props.subscription.id);
- if (session.exists() && props.subscription.remoteId) {
- try {
- await accountApi.deleteSubscription(props.subscription.remoteId);
- } catch (e) {
- console.log(`[ActionBar] Error unsubscribing`, e);
- if ((e instanceof UnauthorizedError)) {
- session.resetAndRedirect(routes.login);
- }
- }
- }
- const newSelected = await subscriptionManager.first(); // May be undefined
- if (newSelected) {
- navigate(routes.forSubscription(newSelected));
- } else {
- navigate(routes.app);
- }
- };
-
- const handleSubscriptionSettings = async () => {
- setSubscriptionSettingsOpen(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(`[ActionBar] Error publishing message`, e);
- setSnackOpen(true);
- }
- }
-
return (
<>
{subscription.mutedUntil ? : }
-
+ setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}>
-
-
-
-
-
-
-
- setSnackOpen(false)}
- message={t("message_bar_error_publishing")}
- />
-
-
- setSubscriptionSettingsOpen(false)}
- />
-
+ setAnchorEl(null)}
+ />
>
);
};
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js
index ede3285..e188d4c 100644
--- a/web/src/components/Navigation.js
+++ b/web/src/components/Navigation.js
@@ -13,7 +13,7 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import VisibilityIcon from '@mui/icons-material/Visibility';
import SubscribeDialog from "./SubscribeDialog";
-import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Tooltip} from "@mui/material";
+import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Menu, Portal, Tooltip} from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
@@ -21,7 +21,16 @@ import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
-import {ChatBubble, Lock, NotificationsOffOutlined, Public, PublicOff, Send} from "@mui/icons-material";
+import {
+ ChatBubble,
+ Lock, Logout,
+ MoreHoriz, MoreVert,
+ NotificationsOffOutlined,
+ Public,
+ PublicOff,
+ Send,
+ Settings
+} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
@@ -33,6 +42,10 @@ import CelebrationIcon from '@mui/icons-material/Celebration';
import UpgradeDialog from "./UpgradeDialog";
import {AccountContext} from "./App";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
+import IconButton from "@mui/material/IconButton";
+import MenuItem from "@mui/material/MenuItem";
+import PopupMenu from "./PopupMenu";
+import SubscriptionPopup from "./SubscriptionPopup";
const navWidth = 280;
@@ -245,19 +258,23 @@ const SubscriptionList = (props) => {
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
- const icon = (subscription.state === ConnectionState.Connecting)
- ?
- : ;
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
+ const icon = (subscription.state === ConnectionState.Connecting)
+ ?
+ : ;
+
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
};
+
return (
{icon}
@@ -283,6 +300,18 @@ const SubscriptionItem = (props) => {
}
+
+ e.stopPropagation()} onClick={(e) => setMenuAnchorEl(e.currentTarget)}>
+
+
+
+ setMenuAnchorEl(null)}
+ />
+
+
);
};
diff --git a/web/src/components/PopupMenu.js b/web/src/components/PopupMenu.js
index 6d39d86..4d22398 100644
--- a/web/src/components/PopupMenu.js
+++ b/web/src/components/PopupMenu.js
@@ -1,4 +1,4 @@
-import {Menu} from "@mui/material";
+import {Fade, Menu} from "@mui/material";
import * as React from "react";
const PopupMenu = (props) => {
@@ -10,6 +10,7 @@ const PopupMenu = (props) => {
open={props.open}
onClose={props.onClose}
onClick={props.onClose}
+ TransitionComponent={Fade}
PaperProps={{
elevation: 0,
sx: {
diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js
index 862e715..1a94918 100644
--- a/web/src/components/Preferences.js
+++ b/web/src/components/Preferences.js
@@ -35,18 +35,17 @@ 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, validTopic, validUrl} from "../app/utils";
+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, UnauthorizedError} from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref";
-import LockIcon from "@mui/icons-material/Lock";
-import {Info, Public, PublicOff} from "@mui/icons-material";
-import DialogContentText from "@mui/material/DialogContentText";
-import ReserveTopicSelect from "./ReserveTopicSelect";
+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";
const Preferences = () => {
return (
@@ -496,22 +495,6 @@ const Reservations = () => {
setDialogOpen(true);
};
- const handleDialogCancel = () => {
- setDialogOpen(false);
- };
-
- const handleDialogSubmit = async (reservation) => {
- setDialogOpen(false);
- try {
- await accountApi.upsertReservation(reservation.topic, reservation.everyone);
- await accountApi.sync();
- console.debug(`[Preferences] Added topic reservation`, reservation);
- } catch (e) {
- console.log(`[Preferences] Error topic reservation.`, e);
- }
- // FIXME handle 401/403/409
- };
-
return (
@@ -526,14 +509,11 @@ const Reservations = () => {
-
- setDialogOpen(false)}
/>
@@ -543,8 +523,9 @@ const Reservations = () => {
const ReservationsTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
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})))
@@ -553,34 +534,13 @@ const ReservationsTable = (props) => {
const handleEditClick = (reservation) => {
setDialogKey(prev => prev+1);
setDialogReservation(reservation);
- setDialogOpen(true);
- };
-
- const handleDialogCancel = () => {
- setDialogOpen(false);
- };
-
- const handleDialogSubmit = async (reservation) => {
- setDialogOpen(false);
- try {
- await accountApi.upsertReservation(reservation.topic, reservation.everyone);
- await accountApi.sync();
- console.debug(`[Preferences] Added topic reservation`, reservation);
- } catch (e) {
- console.log(`[Preferences] Error topic reservation.`, e);
- }
- // FIXME handle 401/403/409
+ setEditDialogOpen(true);
};
const handleDeleteClick = async (reservation) => {
- try {
- await accountApi.deleteReservation(reservation.topic);
- await accountApi.sync();
- console.debug(`[Preferences] Deleted topic reservation`, reservation);
- } catch (e) {
- console.log(`[Preferences] Error topic reservation.`, e);
- }
- // FIXME handle 401/403
+ setDialogKey(prev => prev+1);
+ setDialogReservation(reservation);
+ setDeleteDialogOpen(true);
};
return (
@@ -604,32 +564,32 @@ const ReservationsTable = (props) => {
{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] &&
- } label="Not subscribed" color="primary" variant="outlined"/>
+ } label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
}
handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
@@ -641,79 +601,23 @@ const ReservationsTable = (props) => {
))}
- setEditDialogOpen(false)}
+ />
+ setDeleteDialogOpen(false)}
/>
);
};
-const ReservationsDialog = (props) => {
- const { t } = useTranslation();
- const [topic, setTopic] = useState("");
- const [everyone, setEveryone] = useState("deny-all");
- const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
- const editMode = props.reservation !== null;
- const addButtonEnabled = (() => {
- if (editMode) {
- return true;
- } else if (!validTopic(topic)) {
- return false;
- }
- return props.reservations
- .filter(r => r.topic === topic)
- .length === 0;
- })();
- const handleSubmit = async () => {
- props.onSubmit({
- topic: (editMode) ? props.reservation.topic : topic,
- everyone: everyone
- })
- };
- useEffect(() => {
- if (editMode) {
- setTopic(props.reservation.topic);
- setEveryone(props.reservation.everyone);
- }
- }, [editMode, props.reservation]);
- return (
-
- );
-};
-
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
return;
diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js
new file mode 100644
index 0000000..65a2c16
--- /dev/null
+++ b/web/src/components/ReserveDialogs.js
@@ -0,0 +1,206 @@
+import * as React from 'react';
+import {useContext, useEffect, 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,
+ Autocomplete,
+ Checkbox,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ Select,
+ useMediaQuery
+} from "@mui/material";
+import theme from "./theme";
+import api from "../app/Api";
+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 session from "../app/Session";
+import routes from "./routes";
+import accountApi, {Permission, Role, TopicReservedError, UnauthorizedError} from "../app/AccountApi";
+import ReserveTopicSelect from "./ReserveTopicSelect";
+import {AccountContext} from "./App";
+import DialogActions from "@mui/material/DialogActions";
+import MenuItem from "@mui/material/MenuItem";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
+import ListItemText from "@mui/material/ListItemText";
+import {Check, DeleteForever} from "@mui/icons-material";
+
+export const ReserveAddDialog = (props) => {
+ const { t } = useTranslation();
+ const [topic, setTopic] = useState(props.topic || "");
+ const [everyone, setEveryone] = useState(Permission.DENY_ALL);
+ const [errorText, setErrorText] = useState("");
+ 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 ${t}: ${everyone}`);
+ } catch (e) {
+ console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
+ if ((e instanceof UnauthorizedError)) {
+ session.resetAndRedirect(routes.login);
+ } else if ((e instanceof TopicReservedError)) {
+ setErrorText(t("subscribe_dialog_error_topic_already_reserved"));
+ return;
+ }
+ }
+ props.onClose();
+ // FIXME handle 401/403/409
+ };
+
+ return (
+
+ );
+};
+
+export const ReserveEditDialog = (props) => {
+ const { t } = useTranslation();
+ 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);
+ }
+ }
+ props.onClose();
+ // FIXME handle 401/403/409
+ };
+
+ return (
+
+ );
+};
+
+export const ReserveDeleteDialog = (props) => {
+ const { t } = useTranslation();
+ 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 ${t}`);
+ } catch (e) {
+ console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
+ if ((e instanceof UnauthorizedError)) {
+ session.resetAndRedirect(routes.login);
+ }
+ }
+ props.onClose();
+ // FIXME handle 401/403/409
+ };
+
+ return (
+
+ );
+};
+
diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js
index 9969f20..0d7b05b 100644
--- a/web/src/components/ReserveIcons.js
+++ b/web/src/components/ReserveIcons.js
@@ -3,43 +3,44 @@ import {Lock, Public} from "@mui/icons-material";
import Box from "@mui/material/Box";
export const PermissionReadWrite = React.forwardRef((props, ref) => {
- const size = props.size ?? "medium";
- return ;
+ return ;
});
export const PermissionDenyAll = React.forwardRef((props, ref) => {
- const size = props.size ?? "medium";
- return ;
+ return ;
});
export const PermissionRead = React.forwardRef((props, ref) => {
- return ;
+ return ;
});
export const PermissionWrite = React.forwardRef((props, ref) => {
- return ;
+ return ;
});
-const PermissionReadOrWrite = React.forwardRef((props, ref) => {
+const PermissionInternal = React.forwardRef((props, ref) => {
const size = props.size ?? "medium";
+ const Icon = props.icon;
return (
-
+
+
+ {props.text &&
+
+ {props.text}
+
+ }
+
);
});
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 046a78f..9460f52 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -188,11 +188,11 @@ const SubscribePage = (props) => {
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
- "aria-label": t("subscription_settings_dialog_reserve_topic_label")
+ "aria-label": t("reserve_dialog_checkbox_label")
}}
/>
}
- label={t("subscription_settings_dialog_reserve_topic_label")}
+ label={t("reserve_dialog_checkbox_label")}
/>
{reserveTopicVisible &&
{
+ 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 = !subscription?.reservation && account?.stats.reservations_remaining > 0;
+ const showReservationAddDisabled = !subscription?.reservation && account?.stats.reservations_remaining === 0;
+ const showReservationEdit = !!subscription?.reservation;
+ const showReservationDelete = !!subscription?.reservation;
+
+ 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(`[ActionBar] Error publishing message`, e);
+ setShowPublishError(true);
+ }
+ }
+
+ const handleClearAll = async () => {
+ console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
+ await subscriptionManager.deleteNotifications(props.subscription.id);
+ };
+
+ const handleUnsubscribe = async (event) => {
+ console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription);
+ await subscriptionManager.remove(props.subscription.id);
+ if (session.exists() && props.subscription.remoteId) {
+ try {
+ await accountApi.deleteSubscription(props.subscription.remoteId);
+ } catch (e) {
+ console.log(`[ActionBar] 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 [displayName, setDisplayName] = useState(subscription.displayName ?? "");
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const handleSave = async () => {
+ // Apply locally
+ await subscriptionManager.setDisplayName(subscription.id, displayName);
+
+ // Apply remotely
+ if (session.exists() && subscription.remoteId) {
+ try {
+ console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
+ await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName });
+ } catch (e) {
+ console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
+ if ((e instanceof UnauthorizedError)) {
+ session.resetAndRedirect(routes.login);
+ }
+
+ // FIXME handle 409
+ }
+ }
+ props.onClose();
+ }
+
+ return (
+
+ );
+};
+
+export default SubscriptionPopup;
diff --git a/web/src/components/SubscriptionSettingsDialog.js b/web/src/components/SubscriptionSettingsDialog.js
deleted file mode 100644
index 23c5ec0..0000000
--- a/web/src/components/SubscriptionSettingsDialog.js
+++ /dev/null
@@ -1,115 +0,0 @@
-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 {Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
-import theme from "./theme";
-import subscriptionManager from "../app/SubscriptionManager";
-import DialogFooter from "./DialogFooter";
-import {useTranslation} from "react-i18next";
-import accountApi, {UnauthorizedError} from "../app/AccountApi";
-import session from "../app/Session";
-import routes from "./routes";
-import ReserveTopicSelect from "./ReserveTopicSelect";
-
-const SubscriptionSettingsDialog = (props) => {
- const { t } = useTranslation();
- const subscription = props.subscription;
- const [reserveTopicVisible, setReserveTopicVisible] = useState(!!subscription.reservation);
- const [everyone, setEveryone] = useState(subscription.reservation?.everyone || "deny-all");
- const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
- const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
-
- const handleSave = async () => {
- // Apply locally
- await subscriptionManager.setDisplayName(subscription.id, displayName);
-
- // Apply remotely
- if (session.exists() && subscription.remoteId) {
- try {
- // Display name
- console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
- await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName });
-
- // Reservation
- if (reserveTopicVisible) {
- await accountApi.upsertReservation(subscription.topic, everyone);
- } else if (!reserveTopicVisible && subscription.reservation) { // Was removed
- await accountApi.deleteReservation(subscription.topic);
- }
-
- // Sync account
- await accountApi.sync();
- } catch (e) {
- console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
- if ((e instanceof UnauthorizedError)) {
- session.resetAndRedirect(routes.login);
- }
-
- // FIXME handle 409
- }
- }
- props.onClose();
- }
-
- return (
-
- );
-};
-
-export default SubscriptionSettingsDialog;