Reserve dialogs

This commit is contained in:
binwiederhier 2023-01-31 21:39:30 -05:00
parent 259293f9b3
commit 07cdf2bc7a
13 changed files with 587 additions and 397 deletions

View file

@ -447,6 +447,12 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil { if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
return err 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()) return s.writeJSON(w, newSuccessResponse())
} }

View file

@ -6,7 +6,7 @@
// During web development, you may change values here for rapid testing. // During web development, you may change values here for rapid testing.
var config = { 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", app_root: "/app",
enable_login: true, enable_login: true,
enable_signup: true, enable_signup: true,

View file

@ -1,5 +1,6 @@
{ {
"common_cancel": "Cancel", "common_cancel": "Cancel",
"common_save": "Save",
"signup_title": "Create a ntfy account", "signup_title": "Create a ntfy account",
"signup_form_username": "Username", "signup_form_username": "Username",
"signup_form_password": "Password", "signup_form_password": "Password",
@ -18,7 +19,10 @@
"action_bar_logo_alt": "ntfy logo", "action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",
"action_bar_account": "Account", "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_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications", "action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe", "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_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_example": "Example",
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.", "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"subscription_settings_dialog_title": "Subscription settings", "display_name_dialog_title": "Change display name",
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.", "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.",
"subscription_settings_dialog_display_name_placeholder": "Display name", "display_name_dialog_placeholder": "Display name",
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access", "reserve_dialog_checkbox_label": "Reserve topic and configure access",
"subscription_settings_button_cancel": "Cancel",
"subscription_settings_button_save": "Save",
"notifications_loading": "Loading notifications …", "notifications_loading": "Loading notifications …",
"publish_dialog_title_topic": "Publish to {{topic}}", "publish_dialog_title_topic": "Publish to {{topic}}",
"publish_dialog_title_no_topic": "Publish notification", "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_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_write_only": "I can publish and subscribe, everyone can publish",
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "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_add": "Reserve topic",
"prefs_reservations_dialog_title_edit": "Edit reserved 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_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_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access", "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_min": "min",
"priority_low": "low", "priority_low": "low",
"priority_default": "default", "priority_default": "default",

View file

@ -308,12 +308,15 @@ class AccountApi {
} }
} }
async deleteReservation(topic) { async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic); const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`); console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false"
}
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth(headers, session.token())
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();

View file

@ -7,28 +7,26 @@ import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import {useState} from "react"; import {useState} from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils"; import {topicDisplayName} from "../app/utils";
import db from "../app/db"; import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import api from "../app/Api";
import routes from "./routes"; import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg"; import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {Portal, Snackbar} from "@mui/material";
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
import session from "../app/Session"; import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import {Logout, Person, Settings} from "@mui/icons-material"; import {Logout, Person, Settings} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import SubscriptionPopup from "./SubscriptionPopup";
const ActionBar = (props) => { const ActionBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -86,133 +84,28 @@ const ActionBar = (props) => {
const SettingsIcons = (props) => { const SettingsIcons = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const [snackOpen, setSnackOpen] = useState(false);
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
const subscription = props.subscription; const subscription = props.subscription;
const open = Boolean(anchorEl);
const handleToggleOpen = (event) => {
setAnchorEl(event.currentTarget);
};
const handleToggleMute = async () => { const handleToggleMute = async () => {
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); 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 ( return (
<> <>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}> <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>} {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
</IconButton> </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/> <MoreVertIcon/>
</IconButton> </IconButton>
<PopupMenu <SubscriptionPopup
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} subscription={subscription}
onClose={() => setSubscriptionSettingsOpen(false)} anchor={anchorEl}
placement="right"
onClose={() => setAnchorEl(null)}
/> />
</Portal>
</> </>
); );
}; };

View file

@ -13,7 +13,7 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import SubscribeDialog from "./SubscribeDialog"; 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 Button from "@mui/material/Button";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
@ -21,7 +21,16 @@ import routes from "./routes";
import {ConnectionState} from "../app/Connection"; import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager"; 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 Box from "@mui/material/Box";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import config from "../app/config"; import config from "../app/config";
@ -33,6 +42,10 @@ import CelebrationIcon from '@mui/icons-material/Celebration';
import UpgradeDialog from "./UpgradeDialog"; import UpgradeDialog from "./UpgradeDialog";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; 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; const navWidth = 280;
@ -245,19 +258,23 @@ const SubscriptionList = (props) => {
const SubscriptionItem = (props) => { const SubscriptionItem = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription; const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; 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 displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting) const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${displayName} (${t("nav_button_connecting")})` ? `${displayName} (${t("nav_button_connecting")})`
: displayName; : displayName;
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
const handleClick = async () => { const handleClick = async () => {
navigate(routes.forSubscription(subscription)); navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id); await subscriptionManager.markNotificationsRead(subscription.id);
}; };
return ( return (
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite"> <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon> <ListItemIcon>{icon}</ListItemIcon>
@ -283,6 +300,18 @@ const SubscriptionItem = (props) => {
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip> <Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
</ListItemIcon> </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> </ListItemButton>
); );
}; };

View file

@ -1,4 +1,4 @@
import {Menu} from "@mui/material"; import {Fade, Menu} from "@mui/material";
import * as React from "react"; import * as React from "react";
const PopupMenu = (props) => { const PopupMenu = (props) => {
@ -10,6 +10,7 @@ const PopupMenu = (props) => {
open={props.open} open={props.open}
onClose={props.onClose} onClose={props.onClose}
onClick={props.onClose} onClick={props.onClose}
TransitionComponent={Fade}
PaperProps={{ PaperProps={{
elevation: 0, elevation: 0,
sx: { sx: {

View file

@ -35,18 +35,17 @@ import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager"; 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 {useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission, Role, UnauthorizedError} from "../app/AccountApi"; import accountApi, {Permission, Role, UnauthorizedError} from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import LockIcon from "@mui/icons-material/Lock"; import {Info} from "@mui/icons-material";
import {Info, Public, PublicOff} from "@mui/icons-material";
import DialogContentText from "@mui/material/DialogContentText";
import ReserveTopicSelect from "./ReserveTopicSelect";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import {useOutletContext} from "react-router-dom"; import {useOutletContext} from "react-router-dom";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
const Preferences = () => { const Preferences = () => {
return ( return (
@ -496,22 +495,6 @@ const Reservations = () => {
setDialogOpen(true); 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 ( return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}> <Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
<CardContent sx={{ paddingBottom: 1 }}> <CardContent sx={{ paddingBottom: 1 }}>
@ -526,14 +509,11 @@ const Reservations = () => {
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button> <Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
<ReserveAddDialog
<ReservationsDialog
key={`reservationAddDialog${dialogKey}`} key={`reservationAddDialog${dialogKey}`}
open={dialogOpen} open={dialogOpen}
reservation={null}
reservations={reservations} reservations={reservations}
onCancel={handleDialogCancel} onClose={() => setDialogOpen(false)}
onSubmit={handleDialogSubmit}
/> />
</CardActions> </CardActions>
</Card> </Card>
@ -543,8 +523,9 @@ const Reservations = () => {
const ReservationsTable = (props) => { const ReservationsTable = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogReservation, setDialogReservation] = useState(null); const [dialogReservation, setDialogReservation] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { subscriptions } = useOutletContext(); const { subscriptions } = useOutletContext();
const localSubscriptions = (subscriptions?.length > 0) const localSubscriptions = (subscriptions?.length > 0)
? Object.assign(...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) ? 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) => { const handleEditClick = (reservation) => {
setDialogKey(prev => prev+1); setDialogKey(prev => prev+1);
setDialogReservation(reservation); setDialogReservation(reservation);
setDialogOpen(true); setEditDialogOpen(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
}; };
const handleDeleteClick = async (reservation) => { const handleDeleteClick = async (reservation) => {
try { setDialogKey(prev => prev+1);
await accountApi.deleteReservation(reservation.topic); setDialogReservation(reservation);
await accountApi.sync(); setDeleteDialogOpen(true);
console.debug(`[Preferences] Deleted topic reservation`, reservation);
} catch (e) {
console.log(`[Preferences] Error topic reservation.`, e);
}
// FIXME handle 401/403
}; };
return ( return (
@ -604,32 +564,32 @@ const ReservationsTable = (props) => {
<TableCell aria-label={t("prefs_reservations_table_access_header")}> <TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE && {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")} {t("prefs_reservations_table_everyone_read_write")}
</> </>
} }
{reservation.everyone === Permission.READ_ONLY && {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")} {t("prefs_reservations_table_everyone_read_only")}
</> </>
} }
{reservation.everyone === Permission.WRITE_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")} {t("prefs_reservations_table_everyone_write_only")}
</> </>
} }
{reservation.everyone === Permission.DENY_ALL && {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")} {t("prefs_reservations_table_everyone_deny_all")}
</> </>
} }
</TableCell> </TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] && {!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")}> <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon/> <EditIcon/>
@ -641,79 +601,23 @@ const ReservationsTable = (props) => {
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
<ReservationsDialog <ReserveEditDialog
key={`reservationEditDialog${dialogKey}`} key={`reservationEditDialog${dialogKey}`}
open={dialogOpen} open={editDialogOpen}
reservation={dialogReservation} reservation={dialogReservation}
reservations={props.reservations} reservations={props.reservations}
onCancel={handleDialogCancel} onClose={() => setEditDialogOpen(false)}
onSubmit={handleDialogSubmit} />
<ReserveDeleteDialog
key={`reservationDeleteDialog${dialogKey}`}
open={deleteDialogOpen}
topic={dialogReservation?.topic}
onClose={() => setDeleteDialogOpen(false)}
/> />
</Table> </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) => { const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) { if (!session.exists()) {
return; return;

View file

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

View file

@ -3,28 +3,28 @@ import {Lock, Public} from "@mui/icons-material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
export const PermissionReadWrite = React.forwardRef((props, ref) => { export const PermissionReadWrite = React.forwardRef((props, ref) => {
const size = props.size ?? "medium"; return <PermissionInternal icon={Public} ref={ref} {...props}/>;
return <Public fontSize={size} ref={ref} {...props}/>;
}); });
export const PermissionDenyAll = React.forwardRef((props, ref) => { export const PermissionDenyAll = React.forwardRef((props, ref) => {
const size = props.size ?? "medium"; return <PermissionInternal icon={Lock} ref={ref} {...props}/>;
return <Lock fontSize={size} ref={ref} {...props}/>;
}); });
export const PermissionRead = React.forwardRef((props, ref) => { 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) => { 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 size = props.size ?? "medium";
const Icon = props.icon;
return ( return (
<div ref={ref} {...props} style={{position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px"}}> <Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}>
<Public fontSize={size}/> <Icon fontSize={size} sx={{ color: "gray" }}/>
{props.text &&
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
@ -40,6 +40,7 @@ const PermissionReadOrWrite = React.forwardRef((props, ref) => {
> >
{props.text} {props.text}
</Box> </Box>
</div> }
</Box>
); );
}); });

View file

@ -188,11 +188,11 @@ const SubscribePage = (props) => {
checked={reserveTopicVisible} checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{ 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 && {reserveTopicVisible &&
<ReserveTopicSelect <ReserveTopicSelect

View file

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

View file

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