PWA reload icon, lazy loading: EmojiPicker and MarkdownContent

This commit is contained in:
binwiederhier
2026-06-24 11:44:38 -04:00
parent 8022401728
commit 65e409e9e4
19 changed files with 155 additions and 88 deletions
+10 -1
View File
@@ -11,11 +11,20 @@ if [ -z "$1" ]; then
echo "Example:" echo "Example:"
echo " $0 emoji-converted.json" echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/web/src/app/emojis.js" echo " $0 $ROOTDIR/web/src/app/emojis.js"
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
echo " $0 $ROOTDIR/docs/emojis.md" echo " $0 $ROOTDIR/docs/emojis.md"
exit 1 exit 1
fi fi
if [[ "$1" == *.js ]]; then if [[ "$1" == *emojisMapped.js ]]; then
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
# emoji dataset (emojis.js) stays out of the main web bundle.
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export default " > "$1"
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
echo ";" >> "$1"
elif [[ "$1" == *.js ]]; then
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export const rawEmojis = " > "$1" export const rawEmojis = " > "$1"
+1
View File
@@ -1 +1,2 @@
src/app/emojis.js src/app/emojis.js
src/app/emojisMapped.js
+1
View File
@@ -2,3 +2,4 @@ build/
dist/ dist/
public/static/langs/ public/static/langs/
src/app/emojis.js src/app/emojis.js
src/app/emojisMapped.js
+1
View File
@@ -67,6 +67,7 @@
"action_bar_profile_logout": "Logout", "action_bar_profile_logout": "Logout",
"action_bar_sign_in": "Sign in", "action_bar_sign_in": "Sign in",
"action_bar_sign_up": "Sign up", "action_bar_sign_up": "Sign up",
"action_bar_reload": "Reload app",
"message_bar_type_message": "Type a message here", "message_bar_type_message": "Type a message here",
"message_bar_error_publishing": "Error publishing notification", "message_bar_error_publishing": "Error publishing notification",
"message_bar_show_dialog": "Show publish dialog", "message_bar_show_dialog": "Show publish dialog",
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -48,7 +48,8 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material"; import ContentCopy from "@mui/icons-material/ContentCopy";
import Public from "@mui/icons-material/Public";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import routes from "./routes"; import routes from "./routes";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils"; import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
+30 -1
View File
@@ -6,9 +6,12 @@ import { useLocation, useNavigate } from "react-router-dom";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from "@mui/icons-material/Notifications"; import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import { Logout, Person, Settings } from "@mui/icons-material"; import Logout from "@mui/icons-material/Logout";
import Person from "@mui/icons-material/Person";
import Settings from "@mui/icons-material/Settings";
import session from "../app/Session"; import session from "../app/Session";
import logo from "../img/ntfy.svg"; import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
@@ -88,6 +91,7 @@ const ActionBar = (props) => {
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title} {title}
</Typography> </Typography>
{isLaunchedPWA && <ReloadIcon />}
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />} {props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
<ProfileIcon /> <ProfileIcon />
</Toolbar> </Toolbar>
@@ -124,6 +128,31 @@ const SettingsIcons = (props) => {
); );
}; };
// ReloadIcon hard-refreshes the app. A plain reload would just serve the precached PWA shell,
// so we first purge the service worker caches to force fresh assets from the network.
const ReloadIcon = () => {
const { t } = useTranslation();
const handleReload = async () => {
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
} catch (e) {
console.warn("[ActionBar] Error clearing caches during reload", e);
} finally {
window.location.reload();
}
};
return (
<IconButton color="inherit" size="large" edge="end" onClick={handleReload} aria-label={t("action_bar_reload")}>
<RefreshIcon />
</IconButton>
);
};
const ProfileIcon = () => { const ProfileIcon = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material"; import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
import { Close } from "@mui/icons-material"; import Close from "@mui/icons-material/Close";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { splitNoEmpty } from "../app/utils"; import { splitNoEmpty } from "../app/utils";
import { rawEmojis } from "../app/emojis"; import { rawEmojis } from "../app/emojis";
+2 -1
View File
@@ -4,7 +4,8 @@ import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Visibility, VisibilityOff } from "@mui/icons-material"; import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import session from "../app/Session"; import session from "../app/Session";
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { useEffect } from "react";
import { useRemark } from "react-remark";
import styled from "@emotion/styled";
const MarkdownContainer = styled("div")`
line-height: 1;
h1,
h2,
h3,
h4,
h5,
h6,
p,
pre,
ul,
ol,
blockquote {
margin: 0;
}
p {
line-height: 1.5;
}
blockquote,
pre {
border-radius: 3px;
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
}
pre {
overflow-x: scroll;
padding: 0.9rem;
}
ul,
ol,
blockquote {
padding-inline: 1rem;
}
img {
max-width: 100%;
}
`;
// Renders text/markdown notification bodies. react-remark (and its remark/unified
// dependency stack) is heavy, so this component lives in its own module and is loaded
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
const MarkdownContent = ({ content }) => {
const [reactContent, setMarkdownSource] = useRemark();
useEffect(() => {
setMarkdownSource(content);
}, [content]);
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
};
export default MarkdownContent;
+4 -1
View File
@@ -27,7 +27,10 @@ import Person from "@mui/icons-material/Person";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; import ChatBubble from "@mui/icons-material/ChatBubble";
import MoreVert from "@mui/icons-material/MoreVert";
import NotificationsOffOutlined from "@mui/icons-material/NotificationsOffOutlined";
import Send from "@mui/icons-material/Send";
import ArticleIcon from "@mui/icons-material/Article"; import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
+9 -56
View File
@@ -17,15 +17,13 @@ import {
Button, Button,
} from "@mui/material"; } from "@mui/material";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { Suspense, lazy, useEffect, useState } from "react";
import CheckIcon from "@mui/icons-material/Check"; import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { useRemark } from "react-remark";
import styled from "@emotion/styled";
import { import {
copyToClipboard, copyToClipboard,
formatBytes, formatBytes,
@@ -172,64 +170,19 @@ const autolink = (s) => {
return <>{parts}</>; return <>{parts}</>;
}; };
const MarkdownContainer = styled("div")` // Loaded lazily so the heavy react-remark/unified markdown stack is only fetched when a
line-height: 1; // text/markdown notification is actually rendered (see MarkdownContent.jsx).
const MarkdownContent = lazy(() => import("./MarkdownContent"));
h1,
h2,
h3,
h4,
h5,
h6,
p,
pre,
ul,
ol,
blockquote {
margin: 0;
}
p {
line-height: 1.5;
}
blockquote,
pre {
border-radius: 3px;
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
}
pre {
overflow-x: scroll;
padding: 0.9rem;
}
ul,
ol,
blockquote {
padding-inline: 1rem;
}
img {
max-width: 100%;
}
`;
const MarkdownContent = ({ content }) => {
const [reactContent, setMarkdownSource] = useRemark();
useEffect(() => {
setMarkdownSource(content);
}, [content]);
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
};
const NotificationBody = ({ notification }) => { const NotificationBody = ({ notification }) => {
const displayAsMarkdown = notification.content_type === "text/markdown"; const displayAsMarkdown = notification.content_type === "text/markdown";
const formatted = formatMessage(notification); const formatted = formatMessage(notification);
if (displayAsMarkdown) { if (displayAsMarkdown) {
return <MarkdownContent content={formatted} />; return (
<Suspense fallback={null}>
<MarkdownContent content={formatted} />
</Suspense>
);
} }
return autolink(formatted); return autolink(formatted);
}; };
+1 -1
View File
@@ -33,7 +33,7 @@ import CloseIcon from "@mui/icons-material/Close";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Info } from "@mui/icons-material"; import Info from "@mui/icons-material/Info";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils"; import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
+9 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { Suspense, lazy, useContext, useEffect, useRef, useState } from "react";
import { import {
Checkbox, Checkbox,
Chip, Chip,
@@ -22,7 +22,7 @@ import {
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material"; import Close from "@mui/icons-material/Close";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import priority1 from "../img/priority-1.svg"; import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg"; import priority2 from "../img/priority-2.svg";
@@ -35,13 +35,16 @@ import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import api from "../app/Api"; import api from "../app/Api";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import AccountContext from "./AccountContext"; import AccountContext from "./AccountContext";
// Loaded lazily so the full emoji dataset (~300 KB) is only fetched when the publish
// dialog is opened, not in the initial app bundle (see EmojiPicker.jsx).
const EmojiPicker = lazy(() => import("./EmojiPicker"));
const PublishDialog = (props) => { const PublishDialog = (props) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -398,7 +401,9 @@ const PublishDialog = (props) => {
} }
/> />
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} /> <Suspense fallback={null}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
</Suspense>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}> <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon /> <InsertEmoticonIcon />
</DialogIconButton> </DialogIconButton>
+2 -1
View File
@@ -17,7 +17,8 @@ import {
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, DeleteForever } from "@mui/icons-material"; import Check from "@mui/icons-material/Check";
import DeleteForever from "@mui/icons-material/DeleteForever";
import { validTopic } from "../app/utils"; import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import session from "../app/Session"; import session from "../app/Session";
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Lock, Public } from "@mui/icons-material"; import Lock from "@mui/icons-material/Lock";
import Public from "@mui/icons-material/Public";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />); export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
+2 -1
View File
@@ -4,7 +4,8 @@ import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { Visibility, VisibilityOff } from "@mui/icons-material"; import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import session from "../app/Session"; import session from "../app/Session";
+10 -12
View File
@@ -19,18 +19,16 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import Clear from "@mui/icons-material/Clear";
Clear, import ClearAll from "@mui/icons-material/ClearAll";
ClearAll, import Edit from "@mui/icons-material/Edit";
Edit, import EnhancedEncryption from "@mui/icons-material/EnhancedEncryption";
EnhancedEncryption, import Lock from "@mui/icons-material/Lock";
Lock, import LockOpen from "@mui/icons-material/LockOpen";
LockOpen, import Notifications from "@mui/icons-material/Notifications";
Notifications, import NotificationsOff from "@mui/icons-material/NotificationsOff";
NotificationsOff, import RemoveCircle from "@mui/icons-material/RemoveCircle";
RemoveCircle, import Send from "@mui/icons-material/Send";
Send,
} from "@mui/icons-material";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import accountApi, { Role } from "../app/AccountApi"; import accountApi, { Role } from "../app/AccountApi";
+2 -1
View File
@@ -24,7 +24,8 @@ import {
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material"; import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils"; import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";