import Container from "@mui/material/Container"; import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import * as React from "react"; import {useEffect, useState} from "react"; import { formatBytes, formatMessage, formatShortDateTime, formatTitle, maybeAppendActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils"; import IconButton from "@mui/material/IconButton"; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; import {useLiveQuery} from "dexie-react-hooks"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import subscriptionManager from "../app/SubscriptionManager"; import InfiniteScroll from "react-infinite-scroll-component"; 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"; import AttachmentIcon from "./AttachmentIcon"; import {Trans, useTranslation} from "react-i18next"; import {useOutletContext} from "react-router-dom"; import {useAutoSubscribe} from "./hooks"; export const AllSubscriptions = () => { const { subscriptions } = useOutletContext(); if (!subscriptions) { return <Loading/>; } return <AllSubscriptionsList subscriptions={subscriptions}/>; }; export const SingleSubscription = () => { const { subscriptions, selected } = useOutletContext(); useAutoSubscribe(subscriptions, selected); if (!selected) { return <Loading/>; } return <SingleSubscriptionList subscription={selected}/>; }; const AllSubscriptionsList = (props) => { const subscriptions = props.subscriptions; const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); if (notifications === null || notifications === undefined) { return <Loading/>; } else if (subscriptions.length === 0) { return <NoSubscriptions/>; } else if (notifications.length === 0) { return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>; } return <NotificationList key="all" notifications={notifications} messageBar={false}/>; } const SingleSubscriptionList = (props) => { const subscription = props.subscription; const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); 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}/>; } const NotificationList = (props) => { const { t } = useTranslation(); const pageSize = 20; const notifications = props.notifications; const [snackOpen, setSnackOpen] = useState(false); const [maxCount, setMaxCount] = useState(pageSize); const count = Math.min(notifications.length, maxCount); useEffect(() => { return () => { setMaxCount(pageSize); const main = document.getElementById("main"); if (main) { main.scrollTo(0, 0); } } }, [props.id]); return ( <InfiniteScroll dataLength={count} next={() => setMaxCount(prev => prev + pageSize)} hasMore={count < notifications.length} loader={<>Loading ...</>} scrollThreshold={0.7} scrollableTarget="main" > <Container maxWidth="md" role="list" aria-label={t("notifications_list")} sx={{ marginTop: 3, marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar }} > <Stack spacing={3}> {notifications.slice(0, count).map(notification => <NotificationItem key={notification.id} notification={notification} onShowSnack={() => setSnackOpen(true)} />)} <Snackbar open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} message={t("notifications_copied_to_clipboard")} /> </Stack> </Container> </InfiniteScroll> ); } const NotificationItem = (props) => { const { t } = useTranslation(); const notification = props.notification; const attachment = notification.attachment; const date = formatShortDateTime(notification.time); const otherTags = unmatchedTags(notification.tags); const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; const handleDelete = async () => { console.log(`[Notifications] Deleting notification ${notification.id}`); await subscriptionManager.deleteNotification(notification.id) } const handleMarkRead = async () => { console.log(`[Notifications] Marking notification ${notification.id} as read`); await subscriptionManager.markNotificationRead(notification.id) } const handleCopy = (s) => { navigator.clipboard.writeText(s); props.onShowSnack(); }; const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; const hasAttachmentActions = attachment && !expired; const hasClickAction = notification.click; const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; return ( <Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}> <CardContent> <Tooltip title={t("notifications_delete")} enterDelay={500}> <IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}> <CloseIcon /> </IconButton> </Tooltip> {notification.new === 1 && <Tooltip title={t("notifications_mark_read")} enterDelay={500}> <IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}> <CheckIcon /> </IconButton> </Tooltip>} <Typography sx={{ fontSize: 14 }} color="text.secondary"> {date} {[1,2,4,5].includes(notification.priority) && <img src={priorityFiles[notification.priority]} alt={t("notifications_priority_x", { priority: notification.priority})} style={{ verticalAlign: 'bottom' }} />} {notification.new === 1 && <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")}> <circle cx="50" cy="50" r="50" fill="#338574"/> </svg>} </Typography> {notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>} <Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}> {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} </Typography> {attachment && <Attachment attachment={attachment}/>} {tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>} </CardContent> {showActions && <CardActions sx={{paddingTop: 0}}> {hasAttachmentActions && <> <Tooltip title={t("notifications_attachment_copy_url_title")}> <Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button> </Tooltip> <Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}> <Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button> </Tooltip> </>} {hasClickAction && <> <Tooltip title={t("notifications_click_copy_url_title")}> <Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button> </Tooltip> <Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}> <Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button> </Tooltip> </>} {hasUserActions && <UserActions notification={notification}/>} </CardActions>} </Card> ); } /** * 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}</>; }; const priorityFiles = { 1: priority1, 2: priority2, 4: priority4, 5: priority5 }; const Attachment = (props) => { const { t } = useTranslation(); 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(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) })); } if (expired) { infos.push(t("notifications_attachment_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', }}> <AttachmentIcon type={attachment.type}/> <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)' } }} > <AttachmentIcon type={attachment.type}/> <Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}> <b>{attachment.name}</b> {maybeInfoText} </Typography> </Link> </ButtonBase> ); }; const Image = (props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( <> <Box component="img" src={props.attachment.url} loading="lazy" alt={t("notifications_attachment_image")} onClick={() => setOpen(true)} sx={{ marginTop: 2, borderRadius: '4px', boxShadow: 2, width: 1, maxHeight: '400px', objectFit: 'cover', cursor: 'pointer' }} /> <Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop} > <Fade in={open}> <Box component="img" src={props.attachment.url} alt={t("notifications_attachment_image")} loading="lazy" sx={{ maxWidth: 1, maxHeight: 1, position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: 4, }} /> </Fade> </Modal> </> ); } const UserActions = (props) => { return ( <>{props.notification.actions.map(action => <UserAction key={action.id} notification={props.notification} action={action}/>)}</> ); }; const UserAction = (props) => { const { t } = useTranslation(); const notification = props.notification; const action = props.action; if (action.action === "broadcast") { return ( <Tooltip title={t("notifications_actions_not_supported")}> <span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span> </Tooltip> ); } else if (action.action === "view") { return ( <Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}> <Button onClick={() => openUrl(action.url)} aria-label={t("notifications_actions_open_url_title", { url: action.url })} >{action.label}</Button> </Tooltip> ); } else if (action.action === "http") { const method = action.method ?? "POST"; const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); return ( <Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}> <Button onClick={() => performHttpAction(notification, action)} aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })} >{label}</Button> </Tooltip> ); } return null; // Others }; const performHttpAction = async (notification, action) => { console.log(`[Notifications] Performing HTTP user action`, action); try { updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); const response = await fetch(action.url, { method: action.method ?? "POST", headers: action.headers ?? {}, // This must not null-coalesce to a non nullish value. Otherwise, the fetch API // will reject it for "having a body" body: action.body }); console.log(`[Notifications] HTTP user action response`, response); const success = response.status >= 200 && response.status <= 299; if (success) { updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); } else { updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); } } catch (e) { console.log(`[Notifications] HTTP action failed`, e); updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); } }; const updateActionStatus = (notification, action, progress, error) => { notification.actions = notification.actions.map(a => { if (a.id !== action.id) { return a; } return { ...a, progress: progress, error: error }; }); subscriptionManager.updateNotification(notification); } const ACTION_PROGRESS_ONGOING = 1; const ACTION_PROGRESS_SUCCESS = 2; const ACTION_PROGRESS_FAILED = 3; const ACTION_LABEL_SUFFIX = { [ACTION_PROGRESS_ONGOING]: " …", [ACTION_PROGRESS_SUCCESS]: " ✔", [ACTION_PROGRESS_FAILED]: " ❌" }; const NoNotifications = (props) => { const { t } = useTranslation(); const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); return ( <VerticallyCenteredContainer maxWidth="xs"> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> {t("notifications_none_for_topic_title")} </Typography> <Paragraph> {t("notifications_none_for_topic_description")} </Paragraph> <Paragraph> {t("notifications_example")}:<br/> <tt> $ curl -d "Hi" {shortUrl} </tt> </Paragraph> <Paragraph> <ForMoreDetails/> </Paragraph> </VerticallyCenteredContainer> ); }; const NoNotificationsWithoutSubscription = (props) => { const { t } = useTranslation(); const subscription = props.subscriptions[0]; const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); return ( <VerticallyCenteredContainer maxWidth="xs"> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> {t("notifications_none_for_any_title")} </Typography> <Paragraph> {t("notifications_none_for_any_description")} </Paragraph> <Paragraph> {t("notifications_example")}:<br/> <tt> $ curl -d "Hi" {shortUrl} </tt> </Paragraph> <Paragraph> <ForMoreDetails/> </Paragraph> </VerticallyCenteredContainer> ); }; const NoSubscriptions = () => { const { t } = useTranslation(); return ( <VerticallyCenteredContainer maxWidth="xs"> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> {t("notifications_no_subscriptions_title")} </Typography> <Paragraph> {t("notifications_no_subscriptions_description", { linktext: t("nav_button_subscribe") })} </Paragraph> <Paragraph> <ForMoreDetails/> </Paragraph> </VerticallyCenteredContainer> ); }; const ForMoreDetails = () => { return ( <Trans i18nKey="notifications_more_details" components={{ websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>, docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/> }} /> ); }; const Loading = () => { const { t } = useTranslation(); return ( <VerticallyCenteredContainer> <Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}> <CircularProgress disableShrink sx={{marginBottom: 1}}/><br /> {t("notifications_loading")} </Typography> </VerticallyCenteredContainer> ); };