Notifications.jsx 20 KB

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