SubscriptionPopup.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import * as React from "react";
  2. import { useContext, useState } from "react";
  3. import {
  4. Button,
  5. TextField,
  6. Dialog,
  7. DialogContent,
  8. DialogContentText,
  9. DialogTitle,
  10. Chip,
  11. InputAdornment,
  12. Portal,
  13. Snackbar,
  14. useMediaQuery,
  15. MenuItem,
  16. IconButton,
  17. } from "@mui/material";
  18. import { useTranslation } from "react-i18next";
  19. import { useNavigate } from "react-router-dom";
  20. import { Clear } from "@mui/icons-material";
  21. import theme from "./theme";
  22. import subscriptionManager from "../app/SubscriptionManager";
  23. import DialogFooter from "./DialogFooter";
  24. import accountApi, { Role } from "../app/AccountApi";
  25. import session from "../app/Session";
  26. import routes from "./routes";
  27. import PopupMenu from "./PopupMenu";
  28. import { formatShortDateTime, shuffle } from "../app/utils";
  29. import api from "../app/Api";
  30. import { AccountContext } from "./App";
  31. import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
  32. import { UnauthorizedError } from "../app/errors";
  33. export const SubscriptionPopup = (props) => {
  34. const { t } = useTranslation();
  35. const { account } = useContext(AccountContext);
  36. const navigate = useNavigate();
  37. const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
  38. const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
  39. const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
  40. const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
  41. const [showPublishError, setShowPublishError] = useState(false);
  42. const { subscription } = props;
  43. const placement = props.placement ?? "left";
  44. const reservations = account?.reservations || [];
  45. const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0;
  46. const showReservationAddDisabled =
  47. !showReservationAdd &&
  48. config.enable_reservations &&
  49. !subscription?.reservation &&
  50. (config.enable_payments || account?.stats.reservations_remaining === 0);
  51. const showReservationEdit = config.enable_reservations && !!subscription?.reservation;
  52. const showReservationDelete = config.enable_reservations && !!subscription?.reservation;
  53. const handleChangeDisplayName = async () => {
  54. setDisplayNameDialogOpen(true);
  55. };
  56. const handleReserveAdd = async () => {
  57. setReserveAddDialogOpen(true);
  58. };
  59. const handleReserveEdit = async () => {
  60. setReserveEditDialogOpen(true);
  61. };
  62. const handleReserveDelete = async () => {
  63. setReserveDeleteDialogOpen(true);
  64. };
  65. const handleSendTestMessage = async () => {
  66. const { baseUrl } = props.subscription;
  67. const { topic } = props.subscription;
  68. const tags = shuffle([
  69. "grinning",
  70. "octopus",
  71. "upside_down_face",
  72. "palm_tree",
  73. "maple_leaf",
  74. "apple",
  75. "skull",
  76. "warning",
  77. "jack_o_lantern",
  78. "de-server-1",
  79. "backups",
  80. "cron-script",
  81. "script-error",
  82. "phils-automation",
  83. "mouse",
  84. "go-rocks",
  85. "hi-ben",
  86. ]).slice(0, Math.round(Math.random() * 4));
  87. const priority = shuffle([1, 2, 3, 4, 5])[0];
  88. const title = shuffle([
  89. "",
  90. "",
  91. "", // Higher chance of no title
  92. "Oh my, another test message?",
  93. "Titles are optional, did you know that?",
  94. "ntfy is open source, and will always be free. Cool, right?",
  95. "I don't really like apples",
  96. "My favorite TV show is The Wire. You should watch it!",
  97. "You can attach files and URLs to messages too",
  98. "You can delay messages up to 3 days",
  99. ])[0];
  100. const nowSeconds = Math.round(Date.now() / 1000);
  101. const message = shuffle([
  102. `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
  103. `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.`,
  104. `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.`,
  105. `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
  106. `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 ...`,
  107. `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.`,
  108. `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?`,
  109. ])[0];
  110. try {
  111. await api.publish(baseUrl, topic, message, {
  112. title,
  113. priority,
  114. tags,
  115. });
  116. } catch (e) {
  117. console.log(`[SubscriptionPopup] Error publishing message`, e);
  118. setShowPublishError(true);
  119. }
  120. };
  121. const handleClearAll = async () => {
  122. console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
  123. await subscriptionManager.deleteNotifications(props.subscription.id);
  124. };
  125. const handleUnsubscribe = async () => {
  126. console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
  127. await subscriptionManager.remove(props.subscription.id);
  128. if (session.exists() && !subscription.internal) {
  129. try {
  130. await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
  131. } catch (e) {
  132. console.log(`[SubscriptionPopup] Error unsubscribing`, e);
  133. if (e instanceof UnauthorizedError) {
  134. session.resetAndRedirect(routes.login);
  135. }
  136. }
  137. }
  138. const newSelected = await subscriptionManager.first(); // May be undefined
  139. if (newSelected && !newSelected.internal) {
  140. navigate(routes.forSubscription(newSelected));
  141. } else {
  142. navigate(routes.app);
  143. }
  144. };
  145. return (
  146. <>
  147. <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
  148. <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
  149. {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
  150. {showReservationAddDisabled && (
  151. <MenuItem sx={{ cursor: "default" }}>
  152. <span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
  153. <ReserveLimitChip />
  154. </MenuItem>
  155. )}
  156. {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
  157. {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
  158. <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
  159. <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
  160. <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
  161. </PopupMenu>
  162. <Portal>
  163. <Snackbar
  164. open={showPublishError}
  165. autoHideDuration={3000}
  166. onClose={() => setShowPublishError(false)}
  167. message={t("message_bar_error_publishing")}
  168. />
  169. <DisplayNameDialog open={displayNameDialogOpen} subscription={subscription} onClose={() => setDisplayNameDialogOpen(false)} />
  170. {showReservationAdd && (
  171. <ReserveAddDialog
  172. open={reserveAddDialogOpen}
  173. topic={subscription.topic}
  174. reservations={reservations}
  175. onClose={() => setReserveAddDialogOpen(false)}
  176. />
  177. )}
  178. {showReservationEdit && (
  179. <ReserveEditDialog
  180. open={reserveEditDialogOpen}
  181. reservation={subscription.reservation}
  182. reservations={props.reservations}
  183. onClose={() => setReserveEditDialogOpen(false)}
  184. />
  185. )}
  186. {showReservationDelete && (
  187. <ReserveDeleteDialog
  188. open={reserveDeleteDialogOpen}
  189. topic={subscription.topic}
  190. onClose={() => setReserveDeleteDialogOpen(false)}
  191. />
  192. )}
  193. </Portal>
  194. </>
  195. );
  196. };
  197. const DisplayNameDialog = (props) => {
  198. const { t } = useTranslation();
  199. const { subscription } = props;
  200. const [error, setError] = useState("");
  201. const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
  202. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  203. const handleSave = async () => {
  204. await subscriptionManager.setDisplayName(subscription.id, displayName);
  205. if (session.exists() && !subscription.internal) {
  206. try {
  207. console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
  208. await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });
  209. } catch (e) {
  210. console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
  211. if (e instanceof UnauthorizedError) {
  212. session.resetAndRedirect(routes.login);
  213. } else {
  214. setError(e.message);
  215. return;
  216. }
  217. }
  218. }
  219. props.onClose();
  220. };
  221. return (
  222. <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
  223. <DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
  224. <DialogContent>
  225. <DialogContentText>{t("display_name_dialog_description")}</DialogContentText>
  226. <TextField
  227. autoFocus
  228. placeholder={t("display_name_dialog_placeholder")}
  229. value={displayName}
  230. onChange={(ev) => setDisplayName(ev.target.value)}
  231. type="text"
  232. fullWidth
  233. variant="standard"
  234. inputProps={{
  235. maxLength: 64,
  236. "aria-label": t("display_name_dialog_placeholder"),
  237. }}
  238. InputProps={{
  239. endAdornment: (
  240. <InputAdornment position="end">
  241. <IconButton onClick={() => setDisplayName("")} edge="end">
  242. <Clear />
  243. </IconButton>
  244. </InputAdornment>
  245. ),
  246. }}
  247. />
  248. </DialogContent>
  249. <DialogFooter status={error}>
  250. <Button onClick={props.onClose}>{t("common_cancel")}</Button>
  251. <Button onClick={handleSave}>{t("common_save")}</Button>
  252. </DialogFooter>
  253. </Dialog>
  254. );
  255. };
  256. export const ReserveLimitChip = () => {
  257. const { account } = useContext(AccountContext);
  258. if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
  259. return <></>;
  260. }
  261. if (config.enable_payments) {
  262. return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
  263. }
  264. if (account) {
  265. return <LimitReachedChip />;
  266. }
  267. return <></>;
  268. };
  269. const LimitReachedChip = () => {
  270. const { t } = useTranslation();
  271. return (
  272. <Chip
  273. label={t("action_bar_reservation_limit_reached")}
  274. variant="outlined"
  275. color="primary"
  276. sx={{
  277. opacity: 0.8,
  278. borderWidth: "2px",
  279. height: "24px",
  280. marginLeft: "5px",
  281. }}
  282. />
  283. );
  284. };
  285. export const ProChip = () => (
  286. <Chip
  287. label="ntfy Pro"
  288. variant="outlined"
  289. color="primary"
  290. sx={{
  291. opacity: 0.8,
  292. fontWeight: "bold",
  293. borderWidth: "2px",
  294. height: "24px",
  295. marginLeft: "5px",
  296. }}
  297. />
  298. );