import { Container, ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip, Card, Typography, IconButton, Box, Button, } from "@mui/material"; import * as React from "react"; import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; import InfiniteScroll from "react-infinite-scroll-component"; import { Trans, useTranslation } from "react-i18next"; import { useOutletContext } from "react-router-dom"; import { copyToClipboard, formatBytes, formatDateTime, maybeActionErrors, openUrl, shortUrl, topicUrl, unmatchedTags } from "../app/utils"; import { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../app/actions"; import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; import subscriptionManager from "../app/SubscriptionManager"; import notifier from "../app/Notifier"; 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 { useAutoSubscribe } from "./hooks"; import { usePrefCache } from "./PrefCache"; const priorityFiles = { 1: priority1, 2: priority2, 4: priority4, 5: priority5, }; export const AllSubscriptions = () => { // allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch). const { subscriptions, allNotifications } = useOutletContext(); if (!subscriptions || allNotifications === null || allNotifications === undefined) { return ; } return ; }; export const SingleSubscription = () => { const { subscriptions, selected, allNotifications } = useOutletContext(); useAutoSubscribe(subscriptions, selected); if (!selected || allNotifications === null || allNotifications === undefined) { return ; } return ; }; const AllSubscriptionsList = (props) => { const { subscriptions, notifications } = props; if (subscriptions.length === 0) { return ; } if (notifications.length === 0) { return ; } return ; }; const SingleSubscriptionList = (props) => { const { subscription, allNotifications } = props; // Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) == // getAllNotifications() filtered by id), so topic switches are instant. const notifications = useMemo( () => allNotifications.filter((notification) => notification.subscriptionId === subscription.id), [allNotifications, subscription.id], ); if (notifications.length === 0) { return ; } return ; }; const NotificationList = (props) => { const { t } = useTranslation(); const pageSize = 20; const { notifications } = props; const [snackOpen, setSnackOpen] = useState(false); const [maxCount, setMaxCount] = useState(pageSize); const count = Math.min(notifications.length, maxCount); useEffect( () => () => { setMaxCount(pageSize); const main = document.getElementById("main"); if (main) { main.scrollTo(0, 0); } }, [props.id], ); return ( setMaxCount((prev) => prev + pageSize)} hasMore={count < notifications.length} loader={<>Loading ...} scrollThreshold={0.7} scrollableTarget="main" > {notifications.slice(0, count).map((notification) => ( setSnackOpen(true)} /> ))} setSnackOpen(false)} message={t("notifications_copied_to_clipboard")} /> ); }; /** * Replace links with 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] = ( {shortUrl(parts[i])} ); } return <>{parts}; }; // Loaded lazily so the heavy react-remark/unified markdown stack is only fetched when a // text/markdown notification is actually rendered (see MarkdownContent.jsx). const MarkdownContent = lazy(() => import("./MarkdownContent")); const NotificationBody = ({ notification }) => { const displayAsMarkdown = notification.content_type === "text/markdown"; const formatted = formatMessage(notification); if (displayAsMarkdown) { return ( ); } return autolink(formatted); }; const NotificationItem = (props) => { const { t } = useTranslation(); const { dateFormat, timeFormat } = usePrefCache(); const { notification } = props; const { attachment } = notification; const date = formatDateTime(notification.time, dateFormat, timeFormat); 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) => { copyToClipboard(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 ( {notification.new === 1 && ( )} {date} {[1, 2, 4, 5].includes(notification.priority) && ( {t("notifications_priority_x", )} {notification.new === 1 && ( )} {notification.title && ( {formatTitle(notification)} )} {maybeActionErrors(notification)} {attachment && } {tags && ( {t("notifications_tags")}: {tags} )} {showActions && ( {hasAttachmentActions && ( <> )} {hasClickAction && ( <> )} {hasUserActions && } )} ); }; const Attachment = (props) => { const { t } = useTranslation(); const { dateFormat, timeFormat } = usePrefCache(); const { attachment } = props; const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000; const displayableImage = !expired && isImage(attachment); // Unexpired image if (displayableImage) { return ; } // Anything else: Show box const infos = []; if (attachment.size) { infos.push(formatBytes(attachment.size)); } if (expires) { infos.push( t("notifications_attachment_link_expires", { date: formatDateTime(attachment.expires, dateFormat, timeFormat), }), ); } if (expired) { infos.push(t("notifications_attachment_link_expired")); } const maybeInfoText = infos.length > 0 ? ( <>
{infos.join(", ")} ) : null; // If expired, just show infos without click target if (expired) { return ( {attachment.name} {maybeInfoText} ); } // Not expired return ( {attachment.name} {maybeInfoText} ); }; const Image = (props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( <> setOpen(true)} sx={{ marginTop: 2, borderRadius: "4px", boxShadow: 2, width: 1, maxHeight: "400px", objectFit: "cover", cursor: "pointer", }} /> setOpen(false)} slots={{ backdrop: LightboxBackdrop }}> ); }; const UserActions = (props) => ( <> {props.notification.actions.map((action) => ( ))} ); 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 updateActionStatus = (notification, action, progress, error) => { subscriptionManager.updateNotification({ ...notification, actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)), }); }; const clearNotification = async (notification) => { console.log(`[Notifications] Clearing notification ${notification.id}`); const subscription = await subscriptionManager.get(notification.subscriptionId); if (subscription) { await notifier.cancel(subscription, notification); } await subscriptionManager.markNotificationRead(notification.id); }; 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); if (action.clear) { await clearNotification(notification); } } 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 UserAction = (props) => { const { t } = useTranslation(); const { notification } = props; const { action } = props; if (action.action === ACTION_BROADCAST) { return ( ); } if (action.action === ACTION_VIEW) { const handleClick = () => { openUrl(action.url); if (action.clear) { clearNotification(notification); } }; return ( ); } if (action.action === ACTION_HTTP) { const method = action.method ?? "POST"; const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); return ( ); } if (action.action === ACTION_COPY) { const handleClick = async () => { await copyToClipboard(action.value); props.onShowSnack(); if (action.clear) { await clearNotification(notification); } }; return ( ); } return null; // Others }; const NoNotifications = (props) => { const { t } = useTranslation(); const topicUrlResolved = topicUrl(props.subscription.baseUrl, props.subscription.topic); return ( {t("action_bar_logo_alt")}
{t("notifications_none_for_topic_title")}
{t("notifications_none_for_topic_description")} {t("notifications_example")}:
{'$ curl -d "Hi" '} {topicUrlResolved}
); }; const NoNotificationsWithoutSubscription = (props) => { const { t } = useTranslation(); const subscription = props.subscriptions[0]; const topicUrlResolved = topicUrl(subscription.baseUrl, subscription.topic); return ( {t("action_bar_logo_alt")}
{t("notifications_none_for_any_title")}
{t("notifications_none_for_any_description")} {t("notifications_example")}:
{'$ curl -d "Hi" '} {topicUrlResolved}
); }; const NoSubscriptions = () => { const { t } = useTranslation(); return ( {t("action_bar_logo_alt")}
{t("notifications_no_subscriptions_title")}
{t("notifications_no_subscriptions_description", { linktext: t("nav_button_subscribe"), })}
); }; const ForMoreDetails = () => ( , docsLink: , }} /> ); const Loading = () => { const { t } = useTranslation(); return (
{t("notifications_loading")}
); }; // Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on // genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount. const DeferredLoading = ({ delayMs = 250 }) => { const [show, setShow] = useState(false); useEffect(() => { const timer = setTimeout(() => setShow(true), delayMs); return () => clearTimeout(timer); }, [delayMs]); return show ? : null; };