Notifications.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. import Container from "@mui/material/Container";
  2. import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material";
  3. import Card from "@mui/material/Card";
  4. import Typography from "@mui/material/Typography";
  5. import * as React from "react";
  6. import { useEffect, useState } from "react";
  7. import IconButton from "@mui/material/IconButton";
  8. import CheckIcon from "@mui/icons-material/Check";
  9. import CloseIcon from "@mui/icons-material/Close";
  10. import { useLiveQuery } from "dexie-react-hooks";
  11. import Box from "@mui/material/Box";
  12. import Button from "@mui/material/Button";
  13. import InfiniteScroll from "react-infinite-scroll-component";
  14. import { Trans, useTranslation } from "react-i18next";
  15. import { useOutletContext } from "react-router-dom";
  16. import {
  17. formatBytes,
  18. formatMessage,
  19. formatShortDateTime,
  20. formatTitle,
  21. maybeAppendActionErrors,
  22. openUrl,
  23. shortUrl,
  24. topicShortUrl,
  25. unmatchedTags,
  26. } from "../app/utils";
  27. import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
  28. import subscriptionManager from "../app/SubscriptionManager";
  29. import priority1 from "../img/priority-1.svg";
  30. import priority2 from "../img/priority-2.svg";
  31. import priority4 from "../img/priority-4.svg";
  32. import priority5 from "../img/priority-5.svg";
  33. import logoOutline from "../img/ntfy-outline.svg";
  34. import AttachmentIcon from "./AttachmentIcon";
  35. import { useAutoSubscribe } from "./hooks";
  36. export const AllSubscriptions = () => {
  37. const { subscriptions } = useOutletContext();
  38. if (!subscriptions) {
  39. return <Loading />;
  40. }
  41. return <AllSubscriptionsList subscriptions={subscriptions} />;
  42. };
  43. export const SingleSubscription = () => {
  44. const { subscriptions, selected } = useOutletContext();
  45. useAutoSubscribe(subscriptions, selected);
  46. if (!selected) {
  47. return <Loading />;
  48. }
  49. return <SingleSubscriptionList subscription={selected} />;
  50. };
  51. const AllSubscriptionsList = (props) => {
  52. const { subscriptions } = props;
  53. const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
  54. if (notifications === null || notifications === undefined) {
  55. return <Loading />;
  56. }
  57. if (subscriptions.length === 0) {
  58. return <NoSubscriptions />;
  59. }
  60. if (notifications.length === 0) {
  61. return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
  62. }
  63. return <NotificationList key="all" notifications={notifications} messageBar={false} />;
  64. };
  65. const SingleSubscriptionList = (props) => {
  66. const { subscription } = props;
  67. const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
  68. if (notifications === null || notifications === undefined) {
  69. return <Loading />;
  70. }
  71. if (notifications.length === 0) {
  72. return <NoNotifications subscription={subscription} />;
  73. }
  74. return <NotificationList id={subscription.id} notifications={notifications} messageBar />;
  75. };
  76. const NotificationList = (props) => {
  77. const { t } = useTranslation();
  78. const pageSize = 20;
  79. const { notifications } = props;
  80. const [snackOpen, setSnackOpen] = useState(false);
  81. const [maxCount, setMaxCount] = useState(pageSize);
  82. const count = Math.min(notifications.length, maxCount);
  83. useEffect(
  84. () => () => {
  85. setMaxCount(pageSize);
  86. const main = document.getElementById("main");
  87. if (main) {
  88. main.scrollTo(0, 0);
  89. }
  90. },
  91. [props.id]
  92. );
  93. return (
  94. <InfiniteScroll
  95. dataLength={count}
  96. next={() => setMaxCount((prev) => prev + pageSize)}
  97. hasMore={count < notifications.length}
  98. loader={<>Loading ...</>}
  99. scrollThreshold={0.7}
  100. scrollableTarget="main"
  101. >
  102. <Container
  103. maxWidth="md"
  104. role="list"
  105. aria-label={t("notifications_list")}
  106. sx={{
  107. marginTop: 3,
  108. marginBottom: props.messageBar ? "100px" : 3, // Hack to avoid hiding notifications behind the message bar
  109. }}
  110. >
  111. <Stack spacing={3}>
  112. {notifications.slice(0, count).map((notification) => (
  113. <NotificationItem key={notification.id} notification={notification} onShowSnack={() => setSnackOpen(true)} />
  114. ))}
  115. <Snackbar
  116. open={snackOpen}
  117. autoHideDuration={3000}
  118. onClose={() => setSnackOpen(false)}
  119. message={t("notifications_copied_to_clipboard")}
  120. />
  121. </Stack>
  122. </Container>
  123. </InfiniteScroll>
  124. );
  125. };
  126. const NotificationItem = (props) => {
  127. const { t } = useTranslation();
  128. const { notification } = props;
  129. const { attachment } = notification;
  130. const date = formatShortDateTime(notification.time);
  131. const otherTags = unmatchedTags(notification.tags);
  132. const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
  133. const handleDelete = async () => {
  134. console.log(`[Notifications] Deleting notification ${notification.id}`);
  135. await subscriptionManager.deleteNotification(notification.id);
  136. };
  137. const handleMarkRead = async () => {
  138. console.log(`[Notifications] Marking notification ${notification.id} as read`);
  139. await subscriptionManager.markNotificationRead(notification.id);
  140. };
  141. const handleCopy = (s) => {
  142. navigator.clipboard.writeText(s);
  143. props.onShowSnack();
  144. };
  145. const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;
  146. const hasAttachmentActions = attachment && !expired;
  147. const hasClickAction = notification.click;
  148. const hasUserActions = notification.actions && notification.actions.length > 0;
  149. const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
  150. return (
  151. <Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
  152. <CardContent>
  153. <Tooltip title={t("notifications_delete")} enterDelay={500}>
  154. <IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
  155. <CloseIcon />
  156. </IconButton>
  157. </Tooltip>
  158. {notification.new === 1 && (
  159. <Tooltip title={t("notifications_mark_read")} enterDelay={500}>
  160. <IconButton
  161. onClick={handleMarkRead}
  162. sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
  163. aria-label={t("notifications_mark_read")}
  164. >
  165. <CheckIcon />
  166. </IconButton>
  167. </Tooltip>
  168. )}
  169. <Typography sx={{ fontSize: 14 }} color="text.secondary">
  170. {date}
  171. {[1, 2, 4, 5].includes(notification.priority) && (
  172. <img
  173. src={priorityFiles[notification.priority]}
  174. alt={t("notifications_priority_x", {
  175. priority: notification.priority,
  176. })}
  177. style={{ verticalAlign: "bottom" }}
  178. />
  179. )}
  180. {notification.new === 1 && (
  181. <svg
  182. style={{ width: "8px", height: "8px", marginLeft: "4px" }}
  183. viewBox="0 0 100 100"
  184. xmlns="http://www.w3.org/2000/svg"
  185. aria-label={t("notifications_new_indicator")}
  186. >
  187. <circle cx="50" cy="50" r="50" fill="#338574" />
  188. </svg>
  189. )}
  190. </Typography>
  191. {notification.title && (
  192. <Typography variant="h5" component="div" role="rowheader">
  193. {formatTitle(notification)}
  194. </Typography>
  195. )}
  196. <Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
  197. {autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
  198. </Typography>
  199. {attachment && <Attachment attachment={attachment} />}
  200. {tags && (
  201. <Typography sx={{ fontSize: 14 }} color="text.secondary">
  202. {t("notifications_tags")}: {tags}
  203. </Typography>
  204. )}
  205. </CardContent>
  206. {showActions && (
  207. <CardActions sx={{ paddingTop: 0 }}>
  208. {hasAttachmentActions && (
  209. <>
  210. <Tooltip title={t("notifications_attachment_copy_url_title")}>
  211. <Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
  212. </Tooltip>
  213. <Tooltip
  214. title={t("notifications_attachment_open_title", {
  215. url: attachment.url,
  216. })}
  217. >
  218. <Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
  219. </Tooltip>
  220. </>
  221. )}
  222. {hasClickAction && (
  223. <>
  224. <Tooltip title={t("notifications_click_copy_url_title")}>
  225. <Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
  226. </Tooltip>
  227. <Tooltip
  228. title={t("notifications_actions_open_url_title", {
  229. url: notification.click,
  230. })}
  231. >
  232. <Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
  233. </Tooltip>
  234. </>
  235. )}
  236. {hasUserActions && <UserActions notification={notification} />}
  237. </CardActions>
  238. )}
  239. </Card>
  240. );
  241. };
  242. /**
  243. * Replace links with <Link/> components; this is a combination of the genius function
  244. * in [1] and the regex in [2].
  245. *
  246. * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
  247. * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
  248. */
  249. const autolink = (s) => {
  250. const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
  251. for (let i = 1; i < parts.length; i += 2) {
  252. parts[i] = (
  253. <Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
  254. {shortUrl(parts[i])}
  255. </Link>
  256. );
  257. }
  258. return <>{parts}</>;
  259. };
  260. const priorityFiles = {
  261. 1: priority1,
  262. 2: priority2,
  263. 4: priority4,
  264. 5: priority5,
  265. };
  266. const Attachment = (props) => {
  267. const { t } = useTranslation();
  268. const { attachment } = props;
  269. const expired = attachment.expires && attachment.expires < Date.now() / 1000;
  270. const expires = attachment.expires && attachment.expires > Date.now() / 1000;
  271. const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
  272. // Unexpired image
  273. if (displayableImage) {
  274. return <Image attachment={attachment} />;
  275. }
  276. // Anything else: Show box
  277. const infos = [];
  278. if (attachment.size) {
  279. infos.push(formatBytes(attachment.size));
  280. }
  281. if (expires) {
  282. infos.push(
  283. t("notifications_attachment_link_expires", {
  284. date: formatShortDateTime(attachment.expires),
  285. })
  286. );
  287. }
  288. if (expired) {
  289. infos.push(t("notifications_attachment_link_expired"));
  290. }
  291. const maybeInfoText =
  292. infos.length > 0 ? (
  293. <>
  294. <br />
  295. {infos.join(", ")}
  296. </>
  297. ) : null;
  298. // If expired, just show infos without click target
  299. if (expired) {
  300. return (
  301. <Box
  302. sx={{
  303. display: "flex",
  304. alignItems: "center",
  305. marginTop: 2,
  306. padding: 1,
  307. borderRadius: "4px",
  308. }}
  309. >
  310. <AttachmentIcon type={attachment.type} />
  311. <Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
  312. <b>{attachment.name}</b>
  313. {maybeInfoText}
  314. </Typography>
  315. </Box>
  316. );
  317. }
  318. // Not expired
  319. return (
  320. <ButtonBase
  321. sx={{
  322. marginTop: 2,
  323. }}
  324. >
  325. <Link
  326. href={attachment.url}
  327. target="_blank"
  328. rel="noopener"
  329. underline="none"
  330. sx={{
  331. display: "flex",
  332. alignItems: "center",
  333. padding: 1,
  334. borderRadius: "4px",
  335. "&:hover": {
  336. backgroundColor: "rgba(0, 0, 0, 0.05)",
  337. },
  338. }}
  339. >
  340. <AttachmentIcon type={attachment.type} />
  341. <Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
  342. <b>{attachment.name}</b>
  343. {maybeInfoText}
  344. </Typography>
  345. </Link>
  346. </ButtonBase>
  347. );
  348. };
  349. const Image = (props) => {
  350. const { t } = useTranslation();
  351. const [open, setOpen] = useState(false);
  352. return (
  353. <>
  354. <Box
  355. component="img"
  356. src={props.attachment.url}
  357. loading="lazy"
  358. alt={t("notifications_attachment_image")}
  359. onClick={() => setOpen(true)}
  360. sx={{
  361. marginTop: 2,
  362. borderRadius: "4px",
  363. boxShadow: 2,
  364. width: 1,
  365. maxHeight: "400px",
  366. objectFit: "cover",
  367. cursor: "pointer",
  368. }}
  369. />
  370. <Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
  371. <Fade in={open}>
  372. <Box
  373. component="img"
  374. src={props.attachment.url}
  375. alt={t("notifications_attachment_image")}
  376. loading="lazy"
  377. sx={{
  378. maxWidth: 1,
  379. maxHeight: 1,
  380. position: "absolute",
  381. top: "50%",
  382. left: "50%",
  383. transform: "translate(-50%, -50%)",
  384. padding: 4,
  385. }}
  386. />
  387. </Fade>
  388. </Modal>
  389. </>
  390. );
  391. };
  392. const UserActions = (props) => (
  393. <>
  394. {props.notification.actions.map((action) => (
  395. <UserAction key={action.id} notification={props.notification} action={action} />
  396. ))}
  397. </>
  398. );
  399. const UserAction = (props) => {
  400. const { t } = useTranslation();
  401. const { notification } = props;
  402. const { action } = props;
  403. if (action.action === "broadcast") {
  404. return (
  405. <Tooltip title={t("notifications_actions_not_supported")}>
  406. <span>
  407. <Button disabled aria-label={t("notifications_actions_not_supported")}>
  408. {action.label}
  409. </Button>
  410. </span>
  411. </Tooltip>
  412. );
  413. }
  414. if (action.action === "view") {
  415. return (
  416. <Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
  417. <Button
  418. onClick={() => openUrl(action.url)}
  419. aria-label={t("notifications_actions_open_url_title", {
  420. url: action.url,
  421. })}
  422. >
  423. {action.label}
  424. </Button>
  425. </Tooltip>
  426. );
  427. }
  428. if (action.action === "http") {
  429. const method = action.method ?? "POST";
  430. const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
  431. return (
  432. <Tooltip
  433. title={t("notifications_actions_http_request_title", {
  434. method,
  435. url: action.url,
  436. })}
  437. >
  438. <Button
  439. onClick={() => performHttpAction(notification, action)}
  440. aria-label={t("notifications_actions_http_request_title", {
  441. method,
  442. url: action.url,
  443. })}
  444. >
  445. {label}
  446. </Button>
  447. </Tooltip>
  448. );
  449. }
  450. return null; // Others
  451. };
  452. const performHttpAction = async (notification, action) => {
  453. console.log(`[Notifications] Performing HTTP user action`, action);
  454. try {
  455. updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);
  456. const response = await fetch(action.url, {
  457. method: action.method ?? "POST",
  458. headers: action.headers ?? {},
  459. // This must not null-coalesce to a non nullish value. Otherwise, the fetch API
  460. // will reject it for "having a body"
  461. body: action.body,
  462. });
  463. console.log(`[Notifications] HTTP user action response`, response);
  464. const success = response.status >= 200 && response.status <= 299;
  465. if (success) {
  466. updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
  467. } else {
  468. updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
  469. }
  470. } catch (e) {
  471. console.log(`[Notifications] HTTP action failed`, e);
  472. updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
  473. }
  474. };
  475. const updateActionStatus = (notification, action, progress, error) => {
  476. notification.actions = notification.actions.map((a) => {
  477. if (a.id !== action.id) {
  478. return a;
  479. }
  480. return { ...a, progress, error };
  481. });
  482. subscriptionManager.updateNotification(notification);
  483. };
  484. const ACTION_PROGRESS_ONGOING = 1;
  485. const ACTION_PROGRESS_SUCCESS = 2;
  486. const ACTION_PROGRESS_FAILED = 3;
  487. const ACTION_LABEL_SUFFIX = {
  488. [ACTION_PROGRESS_ONGOING]: " …",
  489. [ACTION_PROGRESS_SUCCESS]: " ✔",
  490. [ACTION_PROGRESS_FAILED]: " ❌",
  491. };
  492. const NoNotifications = (props) => {
  493. const { t } = useTranslation();
  494. const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
  495. return (
  496. <VerticallyCenteredContainer maxWidth="xs">
  497. <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
  498. <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
  499. <br />
  500. {t("notifications_none_for_topic_title")}
  501. </Typography>
  502. <Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
  503. <Paragraph>
  504. {t("notifications_example")}:<br />
  505. <tt>$ curl -d "Hi" {shortUrl}</tt>
  506. </Paragraph>
  507. <Paragraph>
  508. <ForMoreDetails />
  509. </Paragraph>
  510. </VerticallyCenteredContainer>
  511. );
  512. };
  513. const NoNotificationsWithoutSubscription = (props) => {
  514. const { t } = useTranslation();
  515. const subscription = props.subscriptions[0];
  516. const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
  517. return (
  518. <VerticallyCenteredContainer maxWidth="xs">
  519. <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
  520. <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
  521. <br />
  522. {t("notifications_none_for_any_title")}
  523. </Typography>
  524. <Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
  525. <Paragraph>
  526. {t("notifications_example")}:<br />
  527. <tt>$ curl -d "Hi" {shortUrl}</tt>
  528. </Paragraph>
  529. <Paragraph>
  530. <ForMoreDetails />
  531. </Paragraph>
  532. </VerticallyCenteredContainer>
  533. );
  534. };
  535. const NoSubscriptions = () => {
  536. const { t } = useTranslation();
  537. return (
  538. <VerticallyCenteredContainer maxWidth="xs">
  539. <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
  540. <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
  541. <br />
  542. {t("notifications_no_subscriptions_title")}
  543. </Typography>
  544. <Paragraph>
  545. {t("notifications_no_subscriptions_description", {
  546. linktext: t("nav_button_subscribe"),
  547. })}
  548. </Paragraph>
  549. <Paragraph>
  550. <ForMoreDetails />
  551. </Paragraph>
  552. </VerticallyCenteredContainer>
  553. );
  554. };
  555. const ForMoreDetails = () => (
  556. <Trans
  557. i18nKey="notifications_more_details"
  558. components={{
  559. websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
  560. docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
  561. }}
  562. />
  563. );
  564. const Loading = () => {
  565. const { t } = useTranslation();
  566. return (
  567. <VerticallyCenteredContainer>
  568. <Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
  569. <CircularProgress disableShrink sx={{ marginBottom: 1 }} />
  570. <br />
  571. {t("notifications_loading")}
  572. </Typography>
  573. </VerticallyCenteredContainer>
  574. );
  575. };