Notifications.js 21 KB

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