Reserve dialogs
parent
259293f9b3
commit
07cdf2bc7a
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"common_cancel": "Cancel",
|
||||
"common_save": "Save",
|
||||
"signup_title": "Create a ntfy account",
|
||||
"signup_form_username": "Username",
|
||||
"signup_form_password": "Password",
|
||||
|
@ -18,7 +19,10 @@
|
|||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_account": "Account",
|
||||
"action_bar_subscription_settings": "Subscription settings",
|
||||
"action_bar_change_display_name": "Change display name",
|
||||
"action_bar_reservation_add": "Reserve topic",
|
||||
"action_bar_reservation_edit": "Change reservation",
|
||||
"action_bar_reservation_delete": "Remove reservation",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
|
@ -82,12 +86,10 @@
|
|||
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
|
||||
"notifications_example": "Example",
|
||||
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
||||
"subscription_settings_dialog_title": "Subscription settings",
|
||||
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
|
||||
"subscription_settings_dialog_display_name_placeholder": "Display name",
|
||||
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
|
||||
"subscription_settings_button_cancel": "Cancel",
|
||||
"subscription_settings_button_save": "Save",
|
||||
"display_name_dialog_title": "Change display name",
|
||||
"display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
|
||||
"display_name_dialog_placeholder": "Display name",
|
||||
"reserve_dialog_checkbox_label": "Reserve topic and configure access",
|
||||
"notifications_loading": "Loading notifications …",
|
||||
"publish_dialog_title_topic": "Publish to {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publish notification",
|
||||
|
@ -309,11 +311,19 @@
|
|||
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
|
||||
"prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish",
|
||||
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
|
||||
"prefs_reservations_table_not_subscribed": "Not subscribed",
|
||||
"prefs_reservations_dialog_title_add": "Reserve topic",
|
||||
"prefs_reservations_dialog_title_edit": "Edit reserved topic",
|
||||
"prefs_reservations_dialog_title_delete": "Delete topic reservation",
|
||||
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||
"prefs_reservations_dialog_topic_label": "Topic",
|
||||
"prefs_reservations_dialog_access_label": "Access",
|
||||
"reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
|
||||
"reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
|
||||
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",
|
||||
"reservation_delete_dialog_action_delete_title": "Delete cached messages and attachments",
|
||||
"reservation_delete_dialog_action_delete_description": "Cached messages and attachments will be permanently deleted. This action cannot be undone.",
|
||||
"reservation_delete_dialog_submit_button": "Delete reservation",
|
||||
"priority_min": "min",
|
||||
"priority_low": "low",
|
||||
"priority_default": "default",
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -7,28 +7,26 @@ import Typography from "@mui/material/Typography";
|
|||
import * as React from "react";
|
||||
import {useState} from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils";
|
||||
import {topicDisplayName} from "../app/utils";
|
||||
import db from "../app/db";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
|
||||
import api from "../app/Api";
|
||||
import routes from "./routes";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import logo from "../img/ntfy.svg";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Portal, Snackbar} from "@mui/material";
|
||||
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
|
||||
import session from "../app/Session";
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import {Logout, Person, Settings} from "@mui/icons-material";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
import SubscriptionPopup from "./SubscriptionPopup";
|
||||
|
||||
const ActionBar = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -86,133 +84,28 @@ const ActionBar = (props) => {
|
|||
|
||||
const SettingsIcons = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
|
||||
const subscription = props.subscription;
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleToggleOpen = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
|
||||
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleClearAll = async (event) => {
|
||||
console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
|
||||
await subscriptionManager.deleteNotifications(props.subscription.id);
|
||||
};
|
||||
|
||||
const handleUnsubscribe = async (event) => {
|
||||
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription);
|
||||
await subscriptionManager.remove(props.subscription.id);
|
||||
if (session.exists() && props.subscription.remoteId) {
|
||||
try {
|
||||
await accountApi.deleteSubscription(props.subscription.remoteId);
|
||||
} catch (e) {
|
||||
console.log(`[ActionBar] Error unsubscribing`, e);
|
||||
if ((e instanceof UnauthorizedError)) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSelected = await subscriptionManager.first(); // May be undefined
|
||||
if (newSelected) {
|
||||
navigate(routes.forSubscription(newSelected));
|
||||
} else {
|
||||
navigate(routes.app);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscriptionSettings = async () => {
|
||||
setSubscriptionSettingsOpen(true);
|
||||
}
|
||||
|
||||
const handleSendTestMessage = async () => {
|
||||
const baseUrl = props.subscription.baseUrl;
|
||||
const topic = props.subscription.topic;
|
||||
const tags = shuffle([
|
||||
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
|
||||
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
|
||||
.slice(0, Math.round(Math.random() * 4));
|
||||
const priority = shuffle([1, 2, 3, 4, 5])[0];
|
||||
const title = shuffle([
|
||||
"",
|
||||
"",
|
||||
"", // Higher chance of no title
|
||||
"Oh my, another test message?",
|
||||
"Titles are optional, did you know that?",
|
||||
"ntfy is open source, and will always be free. Cool, right?",
|
||||
"I don't really like apples",
|
||||
"My favorite TV show is The Wire. You should watch it!",
|
||||
"You can attach files and URLs to messages too",
|
||||
"You can delay messages up to 3 days"
|
||||
])[0];
|
||||
const nowSeconds = Math.round(Date.now()/1000);
|
||||
const message = shuffle([
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
|
||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
||||
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
|
||||
])[0];
|
||||
try {
|
||||
await api.publish(baseUrl, topic, message, {
|
||||
title: title,
|
||||
priority: priority,
|
||||
tags: tags
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`[ActionBar] Error publishing message`, e);
|
||||
setSnackOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
|
||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}>
|
||||
<MoreVertIcon/>
|
||||
</IconButton>
|
||||
<PopupMenu
|
||||
horizontal="right"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
|
||||
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
||||
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
||||
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
|
||||
</PopupMenu>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("message_bar_error_publishing")}
|
||||
/>
|
||||
</Portal>
|
||||
<Portal>
|
||||
<SubscriptionSettingsDialog
|
||||
key={`subscriptionSettingsDialog${subscription.id}`}
|
||||
open={subscriptionSettingsOpen}
|
||||
subscription={subscription}
|
||||
onClose={() => setSubscriptionSettingsOpen(false)}
|
||||
/>
|
||||
</Portal>
|
||||
<SubscriptionPopup
|
||||
subscription={subscription}
|
||||
anchor={anchorEl}
|
||||
placement="right"
|
||||
onClose={() => setAnchorEl(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,7 +13,7 @@ import SettingsIcon from "@mui/icons-material/Settings";
|
|||
import AddIcon from "@mui/icons-material/Add";
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import SubscribeDialog from "./SubscribeDialog";
|
||||
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Tooltip} from "@mui/material";
|
||||
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Menu, Portal, Tooltip} from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
|
||||
|
@ -21,7 +21,16 @@ import routes from "./routes";
|
|||
import {ConnectionState} from "../app/Connection";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import {ChatBubble, Lock, NotificationsOffOutlined, Public, PublicOff, Send} from "@mui/icons-material";
|
||||
import {
|
||||
ChatBubble,
|
||||
Lock, Logout,
|
||||
MoreHoriz, MoreVert,
|
||||
NotificationsOffOutlined,
|
||||
Public,
|
||||
PublicOff,
|
||||
Send,
|
||||
Settings
|
||||
} from "@mui/icons-material";
|
||||
import Box from "@mui/material/Box";
|
||||
import notifier from "../app/Notifier";
|
||||
import config from "../app/config";
|
||||
|
@ -33,6 +42,10 @@ import CelebrationIcon from '@mui/icons-material/Celebration';
|
|||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import {AccountContext} from "./App";
|
||||
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
import SubscriptionPopup from "./SubscriptionPopup";
|
||||
|
||||
const navWidth = 280;
|
||||
|
||||
|
@ -245,19 +258,23 @@ const SubscriptionList = (props) => {
|
|||
const SubscriptionItem = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
|
||||
|
||||
const subscription = props.subscription;
|
||||
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
|
||||
const icon = (subscription.state === ConnectionState.Connecting)
|
||||
? <CircularProgress size="24px"/>
|
||||
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
|
||||
const displayName = topicDisplayName(subscription);
|
||||
const ariaLabel = (subscription.state === ConnectionState.Connecting)
|
||||
? `${displayName} (${t("nav_button_connecting")})`
|
||||
: displayName;
|
||||
const icon = (subscription.state === ConnectionState.Connecting)
|
||||
? <CircularProgress size="24px"/>
|
||||
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
|
||||
|
||||
const handleClick = async () => {
|
||||
navigate(routes.forSubscription(subscription));
|
||||
await subscriptionManager.markNotificationsRead(subscription.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
|
@ -283,6 +300,18 @@ const SubscriptionItem = (props) => {
|
|||
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
|
||||
</ListItemIcon>
|
||||
}
|
||||
<ListItemIcon edge="end" sx={{minWidth: "26px"}}>
|
||||
<IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => setMenuAnchorEl(e.currentTarget)}>
|
||||
<MoreVert fontSize="small"/>
|
||||
</IconButton>
|
||||
<Portal>
|
||||
<SubscriptionPopup
|
||||
subscription={subscription}
|
||||
anchor={menuAnchorEl}
|
||||
onClose={() => setMenuAnchorEl(null)}
|
||||
/>
|
||||
</Portal>
|
||||
</ListItemIcon>
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -35,18 +35,17 @@ import DialogTitle from "@mui/material/DialogTitle";
|
|||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import userManager from "../app/UserManager";
|
||||
import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
|
||||
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi, {Permission, Role, UnauthorizedError} from "../app/AccountApi";
|
||||
import {Pref, PrefGroup} from "./Pref";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import {Info, Public, PublicOff} from "@mui/icons-material";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||
import {Info} from "@mui/icons-material";
|
||||
import {AccountContext} from "./App";
|
||||
import {useOutletContext} from "react-router-dom";
|
||||
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
|
||||
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
|
||||
|
||||
const Preferences = () => {
|
||||
return (
|
||||
|
@ -496,22 +495,6 @@ const Reservations = () => {
|
|||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async (reservation) => {
|
||||
setDialogOpen(false);
|
||||
try {
|
||||
await accountApi.upsertReservation(reservation.topic, reservation.everyone);
|
||||
await accountApi.sync();
|
||||
console.debug(`[Preferences] Added topic reservation`, reservation);
|
||||
} catch (e) {
|
||||
console.log(`[Preferences] Error topic reservation.`, e);
|
||||
}
|
||||
// FIXME handle 401/403/409
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
|
||||
<CardContent sx={{ paddingBottom: 1 }}>
|
||||
|
@ -526,14 +509,11 @@ const Reservations = () => {
|
|||
</CardContent>
|
||||
<CardActions>
|
||||
<Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
|
||||
|
||||
<ReservationsDialog
|
||||
<ReserveAddDialog
|
||||
key={`reservationAddDialog${dialogKey}`}
|
||||
open={dialogOpen}
|
||||
reservation={null}
|
||||
reservations={reservations}
|
||||
onCancel={handleDialogCancel}
|
||||
onSubmit={handleDialogSubmit}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
/>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
@ -543,8 +523,9 @@ const Reservations = () => {
|
|||
const ReservationsTable = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogReservation, setDialogReservation] = useState(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { subscriptions } = useOutletContext();
|
||||
const localSubscriptions = (subscriptions?.length > 0)
|
||||
? Object.assign(...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s})))
|
||||
|
@ -553,34 +534,13 @@ const ReservationsTable = (props) => {
|
|||
const handleEditClick = (reservation) => {
|
||||
setDialogKey(prev => prev+1);
|
||||
setDialogReservation(reservation);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async (reservation) => {
|
||||
setDialogOpen(false);
|
||||
try {
|
||||
await accountApi.upsertReservation(reservation.topic, reservation.everyone);
|
||||
await accountApi.sync();
|
||||
console.debug(`[Preferences] Added topic reservation`, reservation);
|
||||
} catch (e) {
|
||||
console.log(`[Preferences] Error topic reservation.`, e);
|
||||
}
|
||||
// FIXME handle 401/403/409
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = async (reservation) => {
|
||||
try {
|
||||
await accountApi.deleteReservation(reservation.topic);
|
||||
await accountApi.sync();
|
||||
console.debug(`[Preferences] Deleted topic reservation`, reservation);
|
||||
} catch (e) {
|
||||
console.log(`[Preferences] Error topic reservation.`, e);
|
||||
}
|
||||
// FIXME handle 401/403
|
||||
setDialogKey(prev => prev+1);
|
||||
setDialogReservation(reservation);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -604,32 +564,32 @@ const ReservationsTable = (props) => {
|
|||
<TableCell aria-label={t("prefs_reservations_table_access_header")}>
|
||||
{reservation.everyone === Permission.READ_WRITE &&
|
||||
<>
|
||||
<Public fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||
<PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
|
||||
{t("prefs_reservations_table_everyone_read_write")}
|
||||
</>
|
||||
}
|
||||
{reservation.everyone === Permission.READ_ONLY &&
|
||||
<>
|
||||
<PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||
<PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
|
||||
{t("prefs_reservations_table_everyone_read_only")}
|
||||
</>
|
||||
}
|
||||
{reservation.everyone === Permission.WRITE_ONLY &&
|
||||
<>
|
||||
<PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||
<PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
|
||||
{t("prefs_reservations_table_everyone_write_only")}
|
||||
</>
|
||||
}
|
||||
{reservation.everyone === Permission.DENY_ALL &&
|
||||
<>
|
||||
<LockIcon fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||
<PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
|
||||
{t("prefs_reservations_table_everyone_deny_all")}
|
||||
</>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{!localSubscriptions[reservation.topic] &&
|
||||
<Chip icon={<Info/>} label="Not subscribed" color="primary" variant="outlined"/>
|
||||
<Chip icon={<Info/>} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
|
||||
}
|
||||
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
|
||||
<EditIcon/>
|
||||
|
@ -641,79 +601,23 @@ const ReservationsTable = (props) => {
|
|||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<ReservationsDialog
|
||||
<ReserveEditDialog
|
||||
key={`reservationEditDialog${dialogKey}`}
|
||||
open={dialogOpen}
|
||||
open={editDialogOpen}
|
||||
reservation={dialogReservation}
|
||||
reservations={props.reservations}
|
||||
onCancel={handleDialogCancel}
|
||||
onSubmit={handleDialogSubmit}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
/>
|
||||
<ReserveDeleteDialog
|
||||
key={`reservationDeleteDialog${dialogKey}`}
|
||||
open={deleteDialogOpen}
|
||||
topic={dialogReservation?.topic}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
/>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const ReservationsDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [everyone, setEveryone] = useState("deny-all");
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const editMode = props.reservation !== null;
|
||||
const addButtonEnabled = (() => {
|
||||
if (editMode) {
|
||||
return true;
|
||||
} else if (!validTopic(topic)) {
|
||||
return false;
|
||||
}
|
||||
return props.reservations
|
||||
.filter(r => r.topic === topic)
|
||||
.length === 0;
|
||||
})();
|
||||
const handleSubmit = async () => {
|
||||
props.onSubmit({
|
||||
topic: (editMode) ? props.reservation.topic : topic,
|
||||
everyone: everyone
|
||||
})
|
||||
};
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
setTopic(props.reservation.topic);
|
||||
setEveryone(props.reservation.everyone);
|
||||
}
|
||||
}, [editMode, props.reservation]);
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t("prefs_reservations_dialog_description")}
|
||||
</DialogContentText>
|
||||
{!editMode && <TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="topic"
|
||||
label={t("prefs_reservations_dialog_topic_label")}
|
||||
aria-label={t("prefs_reservations_dialog_topic_label")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>}
|
||||
<ReserveTopicSelect
|
||||
value={everyone}
|
||||
onChange={setEveryone}
|
||||
sx={{mt: 1}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const maybeUpdateAccountSettings = async (payload) => {
|
||||
if (!session.exists()) {
|
||||
return;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,43 +3,44 @@ import {Lock, Public} from "@mui/icons-material";
|
|||
import Box from "@mui/material/Box";
|
||||
|
||||
export const PermissionReadWrite = React.forwardRef((props, ref) => {
|
||||
const size = props.size ?? "medium";
|
||||
return <Public fontSize={size} ref={ref} {...props}/>;
|
||||
return <PermissionInternal icon={Public} ref={ref} {...props}/>;
|
||||
});
|
||||
|
||||
export const PermissionDenyAll = React.forwardRef((props, ref) => {
|
||||
const size = props.size ?? "medium";
|
||||
return <Lock fontSize={size} ref={ref} {...props}/>;
|
||||
return <PermissionInternal icon={Lock} ref={ref} {...props}/>;
|
||||
});
|
||||
|
||||
export const PermissionRead = React.forwardRef((props, ref) => {
|
||||
return <PermissionReadOrWrite text="R" ref={ref} {...props}/>;
|
||||
return <PermissionInternal icon={Public} text="R" ref={ref} {...props}/>;
|
||||
});
|
||||
|
||||
export const PermissionWrite = React.forwardRef((props, ref) => {
|
||||
return <PermissionReadOrWrite text="W" ref={ref} {...props}/>;
|
||||
return <PermissionInternal icon={Public} text="W" ref={ref} {...props}/>;
|
||||
});
|
||||
|
||||
const PermissionReadOrWrite = React.forwardRef((props, ref) => {
|
||||
const PermissionInternal = React.forwardRef((props, ref) => {
|
||||
const size = props.size ?? "medium";
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div ref={ref} {...props} style={{position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px"}}>
|
||||
<Public fontSize={size}/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: "-6px",
|
||||
bottom: "5px",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: "gray",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
marginTop: "3px"
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Box>
|
||||
</div>
|
||||
<Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}>
|
||||
<Icon fontSize={size} sx={{ color: "gray" }}/>
|
||||
{props.text &&
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: "-6px",
|
||||
bottom: "5px",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: "gray",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
marginTop: "3px"
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -188,11 +188,11 @@ const SubscribePage = (props) => {
|
|||
checked={reserveTopicVisible}
|
||||
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("subscription_settings_dialog_reserve_topic_label")
|
||||
"aria-label": t("reserve_dialog_checkbox_label")
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("subscription_settings_dialog_reserve_topic_label")}
|
||||
label={t("reserve_dialog_checkbox_label")}
|
||||
/>
|
||||
{reserveTopicVisible &&
|
||||
<ReserveTopicSelect
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
Loading…
Reference in New Issue