ntfy/web/src/components/Notifications.js

411 lines
16 KiB
JavaScript
Raw Normal View History

2022-02-21 21:24:13 +00:00
import Container from "@mui/material/Container";
2022-03-11 16:46:19 +00:00
import {
ButtonBase,
CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip
} from "@mui/material";
2022-02-21 21:24:13 +00:00
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import * as React from "react";
2022-03-08 16:21:11 +00:00
import {useEffect, useState} from "react";
import {
formatBytes,
formatMessage,
formatShortDateTime,
formatTitle,
2022-03-20 17:52:07 +00:00
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CloseIcon from '@mui/icons-material/Close';
2022-03-04 01:28:16 +00:00
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
import {useLiveQuery} from "dexie-react-hooks";
2022-03-03 01:22:53 +00:00
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
2022-03-08 04:07:07 +00:00
import InfiniteScroll from "react-infinite-scroll-component";
2022-03-09 20:58:21 +00:00
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
2022-04-04 12:40:54 +00:00
import AttachmentIcon from "./AttachmentIcon";
2022-02-21 21:24:13 +00:00
const Notifications = (props) => {
2022-03-07 21:36:49 +00:00
if (props.mode === "all") {
return (props.subscriptions) ? <AllSubscriptions subscriptions={props.subscriptions}/> : <Loading/>;
2022-03-04 21:10:04 +00:00
}
2022-03-07 21:36:49 +00:00
return (props.subscription) ? <SingleSubscription subscription={props.subscription}/> : <Loading/>;
2022-03-05 13:52:52 +00:00
}
2022-03-04 21:10:04 +00:00
2022-03-08 23:56:28 +00:00
const AllSubscriptions = (props) => {
const subscriptions = props.subscriptions;
2022-03-07 21:36:49 +00:00
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading/>;
2022-03-08 23:56:28 +00:00
} else if (subscriptions.length === 0) {
2022-03-07 21:36:49 +00:00
return <NoSubscriptions/>;
2022-03-08 23:56:28 +00:00
} else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
2022-03-07 21:36:49 +00:00
}
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
2022-03-07 21:36:49 +00:00
}
const SingleSubscription = (props) => {
const subscription = props.subscription;
2022-03-08 16:21:11 +00:00
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
2022-03-07 21:36:49 +00:00
if (notifications === null || notifications === undefined) {
return <Loading/>;
} else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>;
}
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
2022-03-07 21:36:49 +00:00
}
const NotificationList = (props) => {
2022-03-08 04:07:07 +00:00
const pageSize = 20;
2022-03-08 16:21:11 +00:00
const notifications = props.notifications;
2022-03-11 16:46:19 +00:00
const [snackOpen, setSnackOpen] = useState(false);
2022-03-08 16:21:11 +00:00
const [maxCount, setMaxCount] = useState(pageSize);
2022-03-08 16:33:17 +00:00
const count = Math.min(notifications.length, maxCount);
2022-03-08 16:21:11 +00:00
useEffect(() => {
return () => {
setMaxCount(pageSize);
document.getElementById("main").scrollTo(0, 0);
}
}, [props.id]);
2022-02-21 21:24:13 +00:00
return (
2022-03-08 04:07:07 +00:00
<InfiniteScroll
dataLength={count}
2022-03-08 16:21:11 +00:00
next={() => setMaxCount(prev => prev + pageSize)}
2022-03-08 04:07:07 +00:00
hasMore={count < notifications.length}
2022-03-11 16:46:19 +00:00
loader={<>Loading ...</>}
2022-03-08 16:21:11 +00:00
scrollThreshold={0.7}
2022-03-08 04:07:07 +00:00
scrollableTarget="main"
>
<Container
maxWidth="md"
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
}}
>
2022-03-08 04:07:07 +00:00
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
<NotificationItem
key={notification.id}
notification={notification}
2022-03-11 16:46:19 +00:00
onShowSnack={() => setSnackOpen(true)}
2022-03-08 04:07:07 +00:00
/>)}
2022-03-11 16:46:19 +00:00
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message="Copied to clipboard"
/>
2022-03-08 04:07:07 +00:00
</Stack>
</Container>
</InfiniteScroll>
2022-02-21 21:24:13 +00:00
);
}
const NotificationItem = (props) => {
const notification = props.notification;
2022-03-03 19:51:56 +00:00
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
2022-02-24 17:26:07 +00:00
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
2022-03-02 21:16:30 +00:00
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
2022-03-02 21:16:30 +00:00
}
2022-03-11 16:46:19 +00:00
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
props.onShowSnack();
};
2022-03-03 19:51:56 +00:00
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
const showAttachmentActions = attachment && !expired;
const showClickAction = notification.click;
const showActions = showAttachmentActions || showClickAction;
2022-02-21 21:24:13 +00:00
return (
2022-03-03 19:51:56 +00:00
<Card sx={{ minWidth: 275, padding: 1 }}>
2022-02-21 21:24:13 +00:00
<CardContent>
2022-03-02 21:16:30 +00:00
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
<CloseIcon />
</IconButton>
2022-02-24 17:26:07 +00:00
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1,2,4,5].includes(notification.priority) &&
<img
2022-03-09 20:58:21 +00:00
src={priorityFiles[notification.priority]}
2022-02-24 17:26:07 +00:00
alt={`Priority ${notification.priority}`}
style={{ verticalAlign: 'bottom' }}
/>}
2022-03-07 03:37:13 +00:00
{notification.new === 1 &&
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50" fill="#338574"/>
</svg>}
2022-02-24 17:26:07 +00:00
</Typography>
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
2022-03-11 16:46:19 +00:00
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{autolink(formatMessage(notification))}</Typography>
2022-03-03 19:51:56 +00:00
{attachment && <Attachment attachment={attachment}/>}
2022-02-21 21:24:13 +00:00
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
</CardContent>
{showActions &&
2022-03-03 19:51:56 +00:00
<CardActions sx={{paddingTop: 0}}>
{showAttachmentActions && <>
2022-03-11 16:46:19 +00:00
<Tooltip title="Copy attachment URL to clipboard">
<Button onClick={() => handleCopy(attachment.url)}>Copy URL</Button>
</Tooltip>
<Tooltip title={`Go to ${attachment.url}`}>
<Button onClick={() => openUrl(attachment.url)}>Open attachment</Button>
</Tooltip>
</>}
{showClickAction && <>
<Tooltip title="Copy link URL to clipboard">
<Button onClick={() => handleCopy(notification.click)}>Copy link</Button>
</Tooltip>
<Tooltip title={`Go to ${notification.click}`}>
<Button onClick={() => openUrl(notification.click)}>Open link</Button>
</Tooltip>
</>}
2022-03-08 04:07:07 +00:00
</CardActions>}
2022-02-21 21:24:13 +00:00
</Card>
);
}
2022-03-11 16:46:19 +00:00
/**
* Replace links with <Link/> components; this is a combination of the genius function
* in [1] and the regex in [2].
*
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = <Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">{shortUrl(parts[i])}</Link>;
}
return <>{parts}</>;
};
2022-03-09 20:58:21 +00:00
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5
};
2022-03-03 19:51:56 +00:00
const Attachment = (props) => {
const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now()/1000;
const expires = attachment.expires && attachment.expires > Date.now()/1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image
if (displayableImage) {
return <Image attachment={attachment}/>;
}
// Anything else: Show box
const infos = [];
if (attachment.size) {
infos.push(formatBytes(attachment.size));
}
if (expires) {
infos.push(`link expires ${formatShortDateTime(attachment.expires)}`);
}
if (expired) {
infos.push(`download link expired`);
}
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
// If expired, just show infos without click target
if (expired) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
marginTop: 2,
padding: 1,
borderRadius: '4px',
}}>
2022-04-04 12:40:54 +00:00
<AttachmentIcon type={attachment.type}/>
2022-03-03 19:51:56 +00:00
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Box>
);
}
// Not expired
return (
<ButtonBase sx={{
marginTop: 2,
}}>
<Link
href={attachment.url}
target="_blank"
rel="noopener"
underline="none"
sx={{
display: 'flex',
alignItems: 'center',
padding: 1,
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.05)'
}
}}
>
2022-04-04 12:40:54 +00:00
<AttachmentIcon type={attachment.type}/>
2022-03-03 19:51:56 +00:00
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Link>
</ButtonBase>
);
};
const Image = (props) => {
const [open, setOpen] = useState(false);
return (
<>
<Box
component="img"
2022-03-09 20:58:21 +00:00
src={props.attachment.url}
2022-03-03 19:51:56 +00:00
loading="lazy"
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
borderRadius: '4px',
boxShadow: 2,
width: 1,
maxHeight: '400px',
objectFit: 'cover',
cursor: 'pointer'
}}
/>
<Modal
open={open}
onClose={() => setOpen(false)}
2022-03-04 01:28:16 +00:00
BackdropComponent={LightboxBackdrop}
2022-03-03 19:51:56 +00:00
>
<Fade in={open}>
<Box
component="img"
2022-03-09 20:58:21 +00:00
src={props.attachment.url}
2022-03-03 19:51:56 +00:00
loading="lazy"
sx={{
maxWidth: 1,
maxHeight: 1,
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: 4,
}}
/>
</Fade>
</Modal>
</>
);
}
2022-03-07 21:36:49 +00:00
const NoNotifications = (props) => {
2022-03-02 21:16:30 +00:00
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
2022-03-09 20:58:21 +00:00
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
You haven't received any notifications for this topic yet.
</Typography>
<Paragraph>
To send notifications to this topic, simply PUT or POST to the topic URL.
</Paragraph>
<Paragraph>
Example:<br/>
<tt>
2022-03-02 21:16:30 +00:00
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
</Paragraph>
</VerticallyCenteredContainer>
);
};
2022-03-08 23:56:28 +00:00
const NoNotificationsWithoutSubscription = (props) => {
const subscription = props.subscriptions[0];
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
2022-03-09 20:58:21 +00:00
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
2022-03-08 23:56:28 +00:00
You haven't received any notifications.
</Typography>
<Paragraph>
To send notifications to a topic, simply PUT or POST to the topic URL. Here's
an example using one of your topics.
</Paragraph>
<Paragraph>
Example:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
</Paragraph>
</VerticallyCenteredContainer>
);
};
2022-03-07 21:36:49 +00:00
const NoSubscriptions = () => {
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
2022-03-09 20:58:21 +00:00
<img src={logoOutline} height="64" width="64" alt="No topics"/><br />
2022-03-07 21:36:49 +00:00
It looks like you don't have any subscriptions yet.
</Typography>
<Paragraph>
Click the "Add subscription" link to create or subscribe to a topic. After that, you can send messages
via PUT or POST and you'll receive notifications here.
</Paragraph>
<Paragraph>
For more information, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
</Paragraph>
</VerticallyCenteredContainer>
);
};
const Loading = () => {
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
Loading notifications ...
</Typography>
</VerticallyCenteredContainer>
);
};
export default Notifications;