diff --git a/docs/releases.md b/docs/releases.md index d958b3fa..7863c15a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1685,6 +1685,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** +* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) * Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) **Bug fixes + maintenance:** @@ -1700,4 +1701,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) * Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) -* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting) +* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting) \ No newline at end of file diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 935f2024..8e27365b 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -8,6 +8,7 @@ import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; import emojisMapped from "./emojisMapped"; +import { THEME } from "./Prefs"; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); @@ -274,6 +275,84 @@ export const urlB64ToUint8Array = (base64String) => { return outputArray; }; +export const darkModeEnabled = (prefersDarkMode, themePreference) => { + switch (themePreference) { + case THEME.DARK: + return true; + + case THEME.LIGHT: + return false; + + case THEME.SYSTEM: + default: + return prefersDarkMode; + } +}; + +// Canvas-based favicon with a red notification dot when there are unread messages +let faviconCanvas; +let faviconOriginalIcon; + +const loadFaviconIcon = () => + new Promise((resolve) => { + if (faviconOriginalIcon) { + resolve(faviconOriginalIcon); + return; + } + const img = new Image(); + img.onload = () => { + faviconOriginalIcon = img; + resolve(img); + }; + img.onerror = () => resolve(null); + // Use PNG instead of ICO — .ico files can't be reliably drawn to canvas in all browsers + img.src = "/static/images/ntfy.png"; + }); + +export const updateFavicon = async (count) => { + const size = 32; + const img = await loadFaviconIcon(); + if (!img) { + return; + } + + if (!faviconCanvas) { + faviconCanvas = document.createElement("canvas"); + faviconCanvas.width = size; + faviconCanvas.height = size; + } + + const ctx = faviconCanvas.getContext("2d"); + ctx.clearRect(0, 0, size, size); + ctx.drawImage(img, 0, 0, size, size); + + if (count > 0) { + const dotRadius = 5; + const borderWidth = 2; + const dotX = size - dotRadius - borderWidth + 1; + const dotY = size - dotRadius - borderWidth + 1; + + // Transparent border: erase a ring around the dot so the icon doesn't bleed into it + ctx.save(); + ctx.globalCompositeOperation = "destination-out"; + ctx.beginPath(); + ctx.arc(dotX, dotY, dotRadius + borderWidth, 0, 2 * Math.PI); + ctx.fill(); + ctx.restore(); + + // Red dot + ctx.beginPath(); + ctx.arc(dotX, dotY, dotRadius, 0, 2 * Math.PI); + ctx.fillStyle = "#dc3545"; + ctx.fill(); + } + + const link = document.querySelector("link[rel='icon']"); + if (link) { + link.href = faviconCanvas.toDataURL("image/png"); + } +}; + export const copyToClipboard = (text) => { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text); diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9a2c3e66..575304d4 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -11,7 +11,7 @@ import ActionBar from "./ActionBar"; import Preferences from "./Preferences"; import subscriptionManager from "../app/SubscriptionManager"; import userManager from "../app/UserManager"; -import { expandUrl, getKebabCaseLangStr } from "../app/utils"; +import { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks"; @@ -21,7 +21,7 @@ import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; import initI18n from "../app/i18n"; // Translations! -import prefs, { THEME } from "../app/Prefs"; +import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; import session from "../app/Session"; @@ -29,20 +29,6 @@ initI18n(); export const AccountContext = createContext(null); -const darkModeEnabled = (prefersDarkMode, themePreference) => { - switch (themePreference) { - case THEME.DARK: - return true; - - case THEME.LIGHT: - return false; - - case THEME.SYSTEM: - default: - return prefersDarkMode; - } -}; - const App = () => { const { i18n } = useTranslation(); const languageDir = i18n.dir(); @@ -97,6 +83,7 @@ const App = () => { const updateTitle = (newNotificationsCount) => { document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; window.navigator.setAppBadge?.(newNotificationsCount); + updateFavicon(newNotificationsCount); }; const Layout = () => {