Notifications.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import Container from "@mui/material/Container";
  2. import {ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Stack} 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 {
  8. formatBytes,
  9. formatMessage,
  10. formatShortDateTime,
  11. formatTitle,
  12. openUrl,
  13. topicShortUrl,
  14. unmatchedTags
  15. } from "../app/utils";
  16. import IconButton from "@mui/material/IconButton";
  17. import CloseIcon from '@mui/icons-material/Close';
  18. import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
  19. import {useLiveQuery} from "dexie-react-hooks";
  20. import Box from "@mui/material/Box";
  21. import Button from "@mui/material/Button";
  22. import subscriptionManager from "../app/SubscriptionManager";
  23. import InfiniteScroll from "react-infinite-scroll-component";
  24. const Notifications = (props) => {
  25. if (props.mode === "all") {
  26. return (props.subscriptions) ? <AllSubscriptions subscriptions={props.subscriptions}/> : <Loading/>;
  27. }
  28. return (props.subscription) ? <SingleSubscription subscription={props.subscription}/> : <Loading/>;
  29. }
  30. const AllSubscriptions = () => {
  31. const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
  32. if (notifications === null || notifications === undefined) {
  33. return <Loading/>;
  34. } else if (notifications.length === 0) {
  35. return <NoSubscriptions/>;
  36. }
  37. return <NotificationList key="all" notifications={notifications}/>;
  38. }
  39. const SingleSubscription = (props) => {
  40. const subscription = props.subscription;
  41. const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
  42. if (notifications === null || notifications === undefined) {
  43. return <Loading/>;
  44. } else if (notifications.length === 0) {
  45. return <NoNotifications subscription={subscription}/>;
  46. }
  47. return <NotificationList id={subscription.id} notifications={notifications}/>;
  48. }
  49. const NotificationList = (props) => {
  50. const pageSize = 20;
  51. const notifications = props.notifications;
  52. const [maxCount, setMaxCount] = useState(pageSize);
  53. // Reset state when the list identifier changes, i.e when we switch between subscriptions
  54. useEffect(() => {
  55. return () => {
  56. setMaxCount(pageSize);
  57. document.getElementById("main").scrollTo(0, 0);
  58. }
  59. }, [props.id]);
  60. const count = Math.min(notifications.length, maxCount);
  61. console.log(`xxx id=${props.id} scrollMax=${maxCount} count=${count} len=${notifications.length}`)
  62. return (
  63. <InfiniteScroll
  64. dataLength={count}
  65. next={() => setMaxCount(prev => prev + pageSize)}
  66. hasMore={count < notifications.length}
  67. loader={<h1>aa</h1>}
  68. scrollThreshold={0.7}
  69. scrollableTarget="main"
  70. >
  71. <Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
  72. <Stack spacing={3}>
  73. {notifications.slice(0, count).map(notification =>
  74. <NotificationItem
  75. key={notification.id}
  76. notification={notification}
  77. />)}
  78. </Stack>
  79. </Container>
  80. </InfiniteScroll>
  81. );
  82. }
  83. const NotificationItem = (props) => {
  84. const notification = props.notification;
  85. const subscriptionId = notification.subscriptionId;
  86. const attachment = notification.attachment;
  87. const date = formatShortDateTime(notification.time);
  88. const otherTags = unmatchedTags(notification.tags);
  89. const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
  90. const handleDelete = async () => {
  91. console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
  92. await subscriptionManager.deleteNotification(notification.id)
  93. }
  94. const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
  95. const showAttachmentActions = attachment && !expired;
  96. const showClickAction = notification.click;
  97. const showActions = showAttachmentActions || showClickAction;
  98. return (
  99. <Card sx={{ minWidth: 275, padding: 1 }}>
  100. <CardContent>
  101. <IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
  102. <CloseIcon />
  103. </IconButton>
  104. <Typography sx={{ fontSize: 14 }} color="text.secondary">
  105. {date}
  106. {[1,2,4,5].includes(notification.priority) &&
  107. <img
  108. src={`/static/img/priority-${notification.priority}.svg`}
  109. alt={`Priority ${notification.priority}`}
  110. style={{ verticalAlign: 'bottom' }}
  111. />}
  112. {notification.new === 1 &&
  113. <svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
  114. <circle cx="50" cy="50" r="50" fill="#338574"/>
  115. </svg>}
  116. </Typography>
  117. {notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
  118. <Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{formatMessage(notification)}</Typography>
  119. {attachment && <Attachment attachment={attachment}/>}
  120. {tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
  121. </CardContent>
  122. {showActions &&
  123. <CardActions sx={{paddingTop: 0}}>
  124. {showAttachmentActions && <>
  125. <Button onClick={() => navigator.clipboard.writeText(attachment.url)}>Copy URL</Button>
  126. <Button onClick={() => openUrl(attachment.url)}>Open attachment</Button>
  127. </>}
  128. {showClickAction && <Button onClick={() => openUrl(notification.click)}>Open link</Button>}
  129. </CardActions>}
  130. </Card>
  131. );
  132. }
  133. const Attachment = (props) => {
  134. const attachment = props.attachment;
  135. const expired = attachment.expires && attachment.expires < Date.now()/1000;
  136. const expires = attachment.expires && attachment.expires > Date.now()/1000;
  137. const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
  138. // Unexpired image
  139. if (displayableImage) {
  140. return <Image attachment={attachment}/>;
  141. }
  142. // Anything else: Show box
  143. const infos = [];
  144. if (attachment.size) {
  145. infos.push(formatBytes(attachment.size));
  146. }
  147. if (expires) {
  148. infos.push(`link expires ${formatShortDateTime(attachment.expires)}`);
  149. }
  150. if (expired) {
  151. infos.push(`download link expired`);
  152. }
  153. const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
  154. // If expired, just show infos without click target
  155. if (expired) {
  156. return (
  157. <Box sx={{
  158. display: 'flex',
  159. alignItems: 'center',
  160. marginTop: 2,
  161. padding: 1,
  162. borderRadius: '4px',
  163. }}>
  164. <Icon type={attachment.type}/>
  165. <Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
  166. <b>{attachment.name}</b>
  167. {maybeInfoText}
  168. </Typography>
  169. </Box>
  170. );
  171. }
  172. // Not expired
  173. return (
  174. <ButtonBase sx={{
  175. marginTop: 2,
  176. }}>
  177. <Link
  178. href={attachment.url}
  179. target="_blank"
  180. rel="noopener"
  181. underline="none"
  182. sx={{
  183. display: 'flex',
  184. alignItems: 'center',
  185. padding: 1,
  186. borderRadius: '4px',
  187. '&:hover': {
  188. backgroundColor: 'rgba(0, 0, 0, 0.05)'
  189. }
  190. }}
  191. >
  192. <Icon type={attachment.type}/>
  193. <Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
  194. <b>{attachment.name}</b>
  195. {maybeInfoText}
  196. </Typography>
  197. </Link>
  198. </ButtonBase>
  199. );
  200. };
  201. const Image = (props) => {
  202. const [open, setOpen] = useState(false);
  203. return (
  204. <>
  205. <Box
  206. component="img"
  207. src={`${props.attachment.url}`}
  208. loading="lazy"
  209. onClick={() => setOpen(true)}
  210. sx={{
  211. marginTop: 2,
  212. borderRadius: '4px',
  213. boxShadow: 2,
  214. width: 1,
  215. maxHeight: '400px',
  216. objectFit: 'cover',
  217. cursor: 'pointer'
  218. }}
  219. />
  220. <Modal
  221. open={open}
  222. onClose={() => setOpen(false)}
  223. BackdropComponent={LightboxBackdrop}
  224. >
  225. <Fade in={open}>
  226. <Box
  227. component="img"
  228. src={`${props.attachment.url}`}
  229. loading="lazy"
  230. sx={{
  231. maxWidth: 1,
  232. maxHeight: 1,
  233. position: 'absolute',
  234. top: '50%',
  235. left: '50%',
  236. transform: 'translate(-50%, -50%)',
  237. padding: 4,
  238. }}
  239. />
  240. </Fade>
  241. </Modal>
  242. </>
  243. );
  244. }
  245. const Icon = (props) => {
  246. const type = props.type;
  247. let imageFile;
  248. if (!type) {
  249. imageFile = 'file-document.svg';
  250. } else if (type.startsWith('image/')) {
  251. imageFile = 'file-image.svg';
  252. } else if (type.startsWith('video/')) {
  253. imageFile = 'file-video.svg';
  254. } else if (type.startsWith('audio/')) {
  255. imageFile = 'file-audio.svg';
  256. } else if (type === "application/vnd.android.package-archive") {
  257. imageFile = 'file-app.svg';
  258. } else {
  259. imageFile = 'file-document.svg';
  260. }
  261. return (
  262. <Box
  263. component="img"
  264. src={`/static/img/${imageFile}`}
  265. loading="lazy"
  266. sx={{
  267. width: '28px',
  268. height: '28px'
  269. }}
  270. />
  271. );
  272. }
  273. const NoNotifications = (props) => {
  274. const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
  275. return (
  276. <VerticallyCenteredContainer maxWidth="xs">
  277. <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
  278. <img src="/static/img/ntfy-outline.svg" height="64" width="64" alt="No notifications"/><br />
  279. You haven't received any notifications for this topic yet.
  280. </Typography>
  281. <Paragraph>
  282. To send notifications to this topic, simply PUT or POST to the topic URL.
  283. </Paragraph>
  284. <Paragraph>
  285. Example:<br/>
  286. <tt>
  287. $ curl -d "Hi" {shortUrl}
  288. </tt>
  289. </Paragraph>
  290. <Paragraph>
  291. For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
  292. {" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
  293. </Paragraph>
  294. </VerticallyCenteredContainer>
  295. );
  296. };
  297. const NoSubscriptions = () => {
  298. return (
  299. <VerticallyCenteredContainer maxWidth="xs">
  300. <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
  301. <img src="/static/img/ntfy-outline.svg" height="64" width="64" alt="No topics"/><br />
  302. It looks like you don't have any subscriptions yet.
  303. </Typography>
  304. <Paragraph>
  305. Click the "Add subscription" link to create or subscribe to a topic. After that, you can send messages
  306. via PUT or POST and you'll receive notifications here.
  307. </Paragraph>
  308. <Paragraph>
  309. For more information, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
  310. {" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
  311. </Paragraph>
  312. </VerticallyCenteredContainer>
  313. );
  314. };
  315. const Loading = () => {
  316. return (
  317. <VerticallyCenteredContainer>
  318. <Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
  319. <CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
  320. Loading notifications ...
  321. </Typography>
  322. </VerticallyCenteredContainer>
  323. );
  324. };
  325. export default Notifications;