فهرست منبع

Reserve dialogs

binwiederhier 3 سال پیش
والد
کامیت
07cdf2bc7a

+ 6 - 0
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())
 }
 

+ 1 - 1
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,

+ 17 - 7
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 <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
-  "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",

+ 5 - 2
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();

+ 10 - 117
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 (
         <>
             <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
                 {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
             </IconButton>
-            <IconButton color="inherit" size="large" edge="end" onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
+            <IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}>
                 <MoreVertIcon/>
             </IconButton>
-            <PopupMenu
-                horizontal="right"
-                anchorEl={anchorEl}
-                open={open}
-                onClose={handleClose}
-            >
-                <MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
-                <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
-                <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
-                <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
-            </PopupMenu>
-            <Portal>
-                <Snackbar
-                    open={snackOpen}
-                    autoHideDuration={3000}
-                    onClose={() => setSnackOpen(false)}
-                    message={t("message_bar_error_publishing")}
-                />
-            </Portal>
-            <Portal>
-                <SubscriptionSettingsDialog
-                    key={`subscriptionSettingsDialog${subscription.id}`}
-                    open={subscriptionSettingsOpen}
-                    subscription={subscription}
-                    onClose={() => setSubscriptionSettingsOpen(false)}
-                />
-            </Portal>
+            <SubscriptionPopup
+                subscription={subscription}
+                anchor={anchorEl}
+                placement="right"
+                onClose={() => setAnchorEl(null)}
+            />
         </>
     );
 };

+ 34 - 5
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)
-        ? <CircularProgress size="24px"/>
-        : <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
     const displayName = topicDisplayName(subscription);
     const ariaLabel = (subscription.state === ConnectionState.Connecting)
         ? `${displayName} (${t("nav_button_connecting")})`
         : displayName;
+    const icon = (subscription.state === ConnectionState.Connecting)
+        ? <CircularProgress size="24px"/>
+        : <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
+
     const handleClick = async () => {
         navigate(routes.forSubscription(subscription));
         await subscriptionManager.markNotificationsRead(subscription.id);
     };
+
     return (
         <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
             <ListItemIcon>{icon}</ListItemIcon>
@@ -283,6 +300,18 @@ const SubscriptionItem = (props) => {
                     <Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
                 </ListItemIcon>
             }
+            <ListItemIcon edge="end" sx={{minWidth: "26px"}}>
+                <IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => setMenuAnchorEl(e.currentTarget)}>
+                    <MoreVert fontSize="small"/>
+                </IconButton>
+                <Portal>
+                    <SubscriptionPopup
+                        subscription={subscription}
+                        anchor={menuAnchorEl}
+                        onClose={() => setMenuAnchorEl(null)}
+                    />
+                </Portal>
+            </ListItemIcon>
         </ListItemButton>
     );
 };

+ 2 - 1
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: {

+ 26 - 122
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 (
         <Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
             <CardContent sx={{ paddingBottom: 1 }}>
@@ -526,14 +509,11 @@ const Reservations = () => {
             </CardContent>
             <CardActions>
                 <Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
-
-                <ReservationsDialog
+                <ReserveAddDialog
                     key={`reservationAddDialog${dialogKey}`}
                     open={dialogOpen}
-                    reservation={null}
                     reservations={reservations}
-                    onCancel={handleDialogCancel}
-                    onSubmit={handleDialogSubmit}
+                    onClose={() => setDialogOpen(false)}
                 />
             </CardActions>
         </Card>
@@ -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) => {
                         <TableCell aria-label={t("prefs_reservations_table_access_header")}>
                             {reservation.everyone === Permission.READ_WRITE &&
                                 <>
-                                    <Public fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
+                                    <PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
                                     {t("prefs_reservations_table_everyone_read_write")}
                                 </>
                             }
                             {reservation.everyone === Permission.READ_ONLY &&
                                 <>
-                                    <PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
+                                    <PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
                                     {t("prefs_reservations_table_everyone_read_only")}
                                 </>
                             }
                             {reservation.everyone === Permission.WRITE_ONLY &&
                                 <>
-                                    <PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
+                                    <PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
                                     {t("prefs_reservations_table_everyone_write_only")}
                                 </>
                             }
                             {reservation.everyone === Permission.DENY_ALL &&
                                 <>
-                                    <LockIcon fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
+                                    <PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
                                     {t("prefs_reservations_table_everyone_deny_all")}
                                 </>
                             }
                         </TableCell>
                         <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
                             {!localSubscriptions[reservation.topic] &&
-                                <Chip icon={<Info/>} label="Not subscribed" color="primary" variant="outlined"/>
+                                <Chip icon={<Info/>} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
                             }
                             <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
                                 <EditIcon/>
@@ -641,79 +601,23 @@ const ReservationsTable = (props) => {
                     </TableRow>
                 ))}
             </TableBody>
-            <ReservationsDialog
+            <ReserveEditDialog
                 key={`reservationEditDialog${dialogKey}`}
-                open={dialogOpen}
+                open={editDialogOpen}
                 reservation={dialogReservation}
                 reservations={props.reservations}
-                onCancel={handleDialogCancel}
-                onSubmit={handleDialogSubmit}
+                onClose={() => setEditDialogOpen(false)}
+            />
+            <ReserveDeleteDialog
+                key={`reservationDeleteDialog${dialogKey}`}
+                open={deleteDialogOpen}
+                topic={dialogReservation?.topic}
+                onClose={() => setDeleteDialogOpen(false)}
             />
         </Table>
     );
 };
 
-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 (
-        <Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}>
-            <DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle>
-            <DialogContent>
-                <DialogContentText>
-                    {t("prefs_reservations_dialog_description")}
-                </DialogContentText>
-                {!editMode && <TextField
-                    autoFocus
-                    margin="dense"
-                    id="topic"
-                    label={t("prefs_reservations_dialog_topic_label")}
-                    aria-label={t("prefs_reservations_dialog_topic_label")}
-                    value={topic}
-                    onChange={ev => setTopic(ev.target.value)}
-                    type="url"
-                    fullWidth
-                    variant="standard"
-                />}
-                <ReserveTopicSelect
-                    value={everyone}
-                    onChange={setEveryone}
-                    sx={{mt: 1}}
-                />
-            </DialogContent>
-            <DialogActions>
-                <Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
-                <Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
-            </DialogActions>
-        </Dialog>
-    );
-};
-
 const maybeUpdateAccountSettings = async (payload) => {
     if (!session.exists()) {
         return;

+ 206 - 0
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 (
+        <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
+            <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {t("prefs_reservations_dialog_description")}
+                </DialogContentText>
+                {allowTopicEdit && <TextField
+                    autoFocus
+                    margin="dense"
+                    id="topic"
+                    label={t("prefs_reservations_dialog_topic_label")}
+                    aria-label={t("prefs_reservations_dialog_topic_label")}
+                    value={topic}
+                    onChange={ev => setTopic(ev.target.value)}
+                    type="url"
+                    fullWidth
+                    variant="standard"
+                />}
+                <ReserveTopicSelect
+                    value={everyone}
+                    onChange={setEveryone}
+                    sx={{mt: 1}}
+                />
+            </DialogContent>
+            <DialogFooter status={errorText}>
+                <Button onClick={props.onClose}>{t("prefs_users_dialog_button_cancel")}</Button>
+                <Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("prefs_users_dialog_button_add")}</Button>
+            </DialogFooter>
+        </Dialog>
+    );
+};
+
+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 (
+        <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
+            <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {t("prefs_reservations_dialog_description")}
+                </DialogContentText>
+                <ReserveTopicSelect
+                    value={everyone}
+                    onChange={setEveryone}
+                    sx={{mt: 1}}
+                />
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={props.onClose}>{t("common_cancel")}</Button>
+                <Button onClick={handleSubmit}>{t("common_save")}</Button>
+            </DialogActions>
+        </Dialog>
+    );
+};
+
+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 (
+        <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
+            <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {t("reservation_delete_dialog_description")}
+                </DialogContentText>
+                <FormControl fullWidth variant="standard">
+                    <Select
+                        value={deleteMessages}
+                        onChange={(ev) => setDeleteMessages(ev.target.value)}
+                        sx={{
+                            "& .MuiSelect-select": {
+                                display: 'flex',
+                                alignItems: 'center',
+                                paddingTop: "4px",
+                                paddingBottom: "4px",
+                            }
+                        }}
+                    >
+                        <MenuItem value={false}>
+                            <ListItemIcon><Check/></ListItemIcon>
+                            <ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/>
+                        </MenuItem>
+                        <MenuItem value={true}>
+                            <ListItemIcon><DeleteForever/></ListItemIcon>
+                            <ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/>
+                        </MenuItem>
+                    </Select>
+                </FormControl>
+                {!deleteMessages &&
+                    <Alert severity="info" sx={{ mt: 1 }}>
+                        {t("reservation_delete_dialog_action_keep_description")}
+                    </Alert>
+                }
+                {deleteMessages &&
+                    <Alert severity="warning" sx={{ mt: 1 }}>
+                        {t("reservation_delete_dialog_action_delete_description")}
+                    </Alert>
+                }
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={props.onClose}>{t("common_cancel")}</Button>
+                <Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button>
+            </DialogActions>
+        </Dialog>
+    );
+};
+

+ 26 - 25
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 <Public fontSize={size} ref={ref} {...props}/>;
+    return <PermissionInternal icon={Public} ref={ref} {...props}/>;
 });
 
 export const PermissionDenyAll = React.forwardRef((props, ref) => {
-    const size = props.size ?? "medium";
-    return <Lock fontSize={size} ref={ref} {...props}/>;
+    return <PermissionInternal icon={Lock} ref={ref} {...props}/>;
 });
 
 export const PermissionRead = React.forwardRef((props, ref) => {
-    return <PermissionReadOrWrite text="R" ref={ref} {...props}/>;
+    return <PermissionInternal icon={Public} text="R" ref={ref} {...props}/>;
 });
 
 export const PermissionWrite = React.forwardRef((props, ref) => {
-    return <PermissionReadOrWrite text="W" ref={ref} {...props}/>;
+    return <PermissionInternal icon={Public} text="W" ref={ref} {...props}/>;
 });
 
-const PermissionReadOrWrite = React.forwardRef((props, ref) => {
+const PermissionInternal = React.forwardRef((props, ref) => {
     const size = props.size ?? "medium";
+    const Icon = props.icon;
     return (
-        <div ref={ref} {...props} style={{position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px"}}>
-            <Public fontSize={size}/>
-            <Box
-                sx={{
-                    position: "absolute",
-                    right: "-6px",
-                    bottom: "5px",
-                    fontSize: 10,
-                    fontWeight: 600,
-                    color: "gray",
-                    width: "8px",
-                    height: "8px",
-                    marginTop: "3px"
-                }}
-            >
-                {props.text}
-            </Box>
-        </div>
+        <Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}>
+            <Icon fontSize={size} sx={{ color: "gray" }}/>
+            {props.text &&
+                <Box
+                    sx={{
+                        position: "absolute",
+                        right: "-6px",
+                        bottom: "5px",
+                        fontSize: 10,
+                        fontWeight: 600,
+                        color: "gray",
+                        width: "8px",
+                        height: "8px",
+                        marginTop: "3px"
+                    }}
+                >
+                    {props.text}
+                </Box>
+            }
+        </Box>
     );
 });

+ 2 - 2
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 &&
                             <ReserveTopicSelect

+ 252 - 0
web/src/components/SubscriptionPopup.js

@@ -0,0 +1,252 @@
+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 {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, {Permission, UnauthorizedError} from "../app/AccountApi";
+import session from "../app/Session";
+import routes from "./routes";
+import ReserveTopicSelect from "./ReserveTopicSelect";
+import MenuItem from "@mui/material/MenuItem";
+import PopupMenu from "./PopupMenu";
+import {formatShortDateTime, shuffle} from "../app/utils";
+import api from "../app/Api";
+import {useNavigate} from "react-router-dom";
+import IconButton from "@mui/material/IconButton";
+import {Clear} from "@mui/icons-material";
+import {AccountContext} from "./App";
+import {ReserveEditDialog, ReserveAddDialog, ReserveDeleteDialog} from "./ReserveDialogs";
+
+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 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 (
+        <>
+            <PopupMenu
+                horizontal={placement}
+                anchorEl={props.anchor}
+                open={!!props.anchor}
+                onClose={props.onClose}
+            >
+                <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
+                {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
+                {showReservationAddDisabled && <MenuItem disabled={true}>{t("action_bar_reservation_add")}</MenuItem>}
+                {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
+                {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
+                <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
+                <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
+                <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
+            </PopupMenu>
+            <Portal>
+                <Snackbar
+                    open={showPublishError}
+                    autoHideDuration={3000}
+                    onClose={() => setShowPublishError(false)}
+                    message={t("message_bar_error_publishing")}
+                />
+                <DisplayNameDialog
+                    open={displayNameDialogOpen}
+                    subscription={subscription}
+                    onClose={() => setDisplayNameDialogOpen(false)}
+                />
+                {showReservationAdd &&
+                    <ReserveAddDialog
+                        open={reserveAddDialogOpen}
+                        topic={subscription.topic}
+                        reservations={reservations}
+                        onClose={() => setReserveAddDialogOpen(false)}
+                    />
+                }
+                {showReservationEdit &&
+                    <ReserveEditDialog
+                        open={reserveEditDialogOpen}
+                        reservation={subscription.reservation}
+                        reservations={props.reservations}
+                        onClose={() => setReserveEditDialogOpen(false)}
+                    />
+                }
+                {showReservationDelete &&
+                    <ReserveDeleteDialog
+                        open={reserveDeleteDialogOpen}
+                        topic={subscription.topic}
+                        onClose={() => setReserveDeleteDialogOpen(false)}
+                    />
+                }
+            </Portal>
+        </>
+    );
+};
+
+const DisplayNameDialog = (props) => {
+    const { t } = useTranslation();
+    const subscription = props.subscription;
+    const [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 (
+        <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
+            <DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {t("display_name_dialog_description")}
+                </DialogContentText>
+                <TextField
+                    autoFocus
+                    placeholder={t("display_name_dialog_placeholder")}
+                    value={displayName}
+                    onChange={ev => setDisplayName(ev.target.value)}
+                    type="text"
+                    fullWidth
+                    variant="standard"
+                    inputProps={{
+                        maxLength: 64,
+                        "aria-label": t("display_name_dialog_placeholder")
+                    }}
+                    InputProps={{
+                        endAdornment: (
+                            <InputAdornment position="end">
+                                <IconButton onClick={() => setDisplayName("")} edge="end">
+                                    <Clear/>
+                                </IconButton>
+                            </InputAdornment>
+                        )
+                    }}
+                />
+            </DialogContent>
+            <DialogFooter>
+                <Button onClick={props.onClose}>{t("common_cancel")}</Button>
+                <Button onClick={handleSave}>{t("common_save")}</Button>
+            </DialogFooter>
+        </Dialog>
+    );
+};
+
+export default SubscriptionPopup;

+ 0 - 115
web/src/components/SubscriptionSettingsDialog.js

@@ -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 (
-        <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
-            <DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
-            <DialogContent>
-                <DialogContentText>
-                    {t("subscription_settings_dialog_description")}
-                </DialogContentText>
-                <TextField
-                    autoFocus
-                    margin="dense"
-                    id="topic"
-                    placeholder={t("subscription_settings_dialog_display_name_placeholder")}
-                    value={displayName}
-                    onChange={ev => setDisplayName(ev.target.value)}
-                    type="text"
-                    fullWidth
-                    variant="standard"
-                    inputProps={{
-                        maxLength: 64,
-                        "aria-label": t("subscription_settings_dialog_display_name_placeholder")
-                    }}
-                />
-                {config.enable_reservations && session.exists() &&
-                    <>
-                        <FormControlLabel
-                            fullWidth
-                            variant="standard"
-                            sx={{pt: 1}}
-                            control={
-                                <Checkbox
-                                    checked={reserveTopicVisible}
-                                    onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
-                                    inputProps={{
-                                        "aria-label": t("subscription_settings_dialog_reserve_topic_label")
-                                    }}
-                                />
-                            }
-                            label={t("subscription_settings_dialog_reserve_topic_label")}
-                        />
-                        {reserveTopicVisible &&
-                            <ReserveTopicSelect
-                                value={everyone}
-                                onChange={setEveryone}
-                            />
-                        }
-                    </>
-                }
-            </DialogContent>
-            <DialogFooter>
-                <Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
-                <Button onClick={handleSave}>{t("subscription_settings_button_save")}</Button>
-            </DialogFooter>
-        </Dialog>
-    );
-};
-
-export default SubscriptionSettingsDialog;