From 07cdf2bc7a8b973ef8f7c4148500d66d2d07b220 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 31 Jan 2023 21:39:30 -0500 Subject: [PATCH] Reserve dialogs --- server/server_account.go | 6 + web/public/config.js | 2 +- web/public/static/langs/en.json | 24 +- web/src/app/AccountApi.js | 7 +- web/src/components/ActionBar.js | 127 +-------- web/src/components/Navigation.js | 39 ++- web/src/components/PopupMenu.js | 3 +- web/src/components/Preferences.js | 148 ++-------- web/src/components/ReserveDialogs.js | 206 ++++++++++++++ web/src/components/ReserveIcons.js | 51 ++-- web/src/components/SubscribeDialog.js | 4 +- web/src/components/SubscriptionPopup.js | 252 ++++++++++++++++++ .../components/SubscriptionSettingsDialog.js | 115 -------- 13 files changed, 587 insertions(+), 397 deletions(-) create mode 100644 web/src/components/ReserveDialogs.js create mode 100644 web/src/components/SubscriptionPopup.js delete mode 100644 web/src/components/SubscriptionSettingsDialog.js diff --git a/server/server_account.go b/server/server_account.go index 1fcfabef..432b6998 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 61f5ca0a..3113ff48 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 652282b8..b8976096 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 31df0a82..512fd1be 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 4ad40680..e58f1500 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")}> - - {t("action_bar_subscription_settings")} - {t("action_bar_send_test_notification")} - {t("action_bar_clear_notifications")} - {t("action_bar_unsubscribe")} - - - 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 ede3285b..e188d4ca 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 6d39d86e..4d22398b 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 862e715c..1a949188 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 ( - - {editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")} - - - {t("prefs_reservations_dialog_description")} - - {!editMode && setTopic(ev.target.value)} - type="url" - fullWidth - variant="standard" - />} - - - - - - - - ); -}; - 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 00000000..65a2c16b --- /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 ( + + {t("prefs_reservations_dialog_title_add")} + + + {t("prefs_reservations_dialog_description")} + + {allowTopicEdit && setTopic(ev.target.value)} + type="url" + fullWidth + variant="standard" + />} + + + + + + + + ); +}; + +export const ReserveEditDialog = (props) => { + const { t } = useTranslation(); + const [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 ( + + {t("prefs_reservations_dialog_title_edit")} + + + {t("prefs_reservations_dialog_description")} + + + + + + + + + ); +}; + +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 ( + + {t("prefs_reservations_dialog_title_delete")} + + + {t("reservation_delete_dialog_description")} + + + + + {!deleteMessages && + + {t("reservation_delete_dialog_action_keep_description")} + + } + {deleteMessages && + + {t("reservation_delete_dialog_action_delete_description")} + + } + + + + + + + ); +}; + diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js index 9969f20f..0d7b05bd 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 && + + {props.text} + + } + ); }); diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 046a78f6..9460f523 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 ( + <> + + {t("action_bar_change_display_name")} + {showReservationAdd && {t("action_bar_reservation_add")}} + {showReservationAddDisabled && {t("action_bar_reservation_add")}} + {showReservationEdit && {t("action_bar_reservation_edit")}} + {showReservationDelete && {t("action_bar_reservation_delete")}} + {t("action_bar_send_test_notification")} + {t("action_bar_clear_notifications")} + {t("action_bar_unsubscribe")} + + + 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 ( + + {t("display_name_dialog_title")} + + + {t("display_name_dialog_description")} + + setDisplayName(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("display_name_dialog_placeholder") + }} + InputProps={{ + endAdornment: ( + + setDisplayName("")} edge="end"> + + + + ) + }} + /> + + + + + + + ); +}; + +export default SubscriptionPopup; diff --git a/web/src/components/SubscriptionSettingsDialog.js b/web/src/components/SubscriptionSettingsDialog.js deleted file mode 100644 index 23c5ec05..00000000 --- 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 ( - - {t("subscription_settings_dialog_title")} - - - {t("subscription_settings_dialog_description")} - - 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() && - <> - setReserveTopicVisible(ev.target.checked)} - inputProps={{ - "aria-label": t("subscription_settings_dialog_reserve_topic_label") - }} - /> - } - label={t("subscription_settings_dialog_reserve_topic_label")} - /> - {reserveTopicVisible && - - } - - } - - - - - - - ); -}; - -export default SubscriptionSettingsDialog;