Notifications.jsx 20 KB

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