1
0
Эх сурвалжийг харах

Web: Show red notification dot on favicon when there are unread messages

binwiederhier 1 долоо хоног өмнө
parent
commit
fe6ee1efa0

+ 2 - 1
docs/releases.md

@@ -1685,6 +1685,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 
 **Features:**
 **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)
 * 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:**
 **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))
 * 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: 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: 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)

+ 79 - 0
web/src/app/utils.js

@@ -8,6 +8,7 @@ import pop from "../sounds/pop.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
 import config from "./config";
 import config from "./config";
 import emojisMapped from "./emojisMapped";
 import emojisMapped from "./emojisMapped";
+import { THEME } from "./Prefs";
 
 
 export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
 export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
@@ -274,6 +275,84 @@ export const urlB64ToUint8Array = (base64String) => {
   return outputArray;
   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) => {
 export const copyToClipboard = (text) => {
   if (navigator.clipboard && window.isSecureContext) {
   if (navigator.clipboard && window.isSecureContext) {
     return navigator.clipboard.writeText(text);
     return navigator.clipboard.writeText(text);

+ 3 - 16
web/src/components/App.jsx

@@ -11,7 +11,7 @@ import ActionBar from "./ActionBar";
 import Preferences from "./Preferences";
 import Preferences from "./Preferences";
 import subscriptionManager from "../app/SubscriptionManager";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
 import userManager from "../app/UserManager";
-import { expandUrl, getKebabCaseLangStr } from "../app/utils";
+import { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from "../app/utils";
 import ErrorBoundary from "./ErrorBoundary";
 import ErrorBoundary from "./ErrorBoundary";
 import routes from "./routes";
 import routes from "./routes";
 import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
 import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
@@ -21,7 +21,7 @@ import Login from "./Login";
 import Signup from "./Signup";
 import Signup from "./Signup";
 import Account from "./Account";
 import Account from "./Account";
 import initI18n from "../app/i18n"; // Translations!
 import initI18n from "../app/i18n"; // Translations!
-import prefs, { THEME } from "../app/Prefs";
+import prefs from "../app/Prefs";
 import RTLCacheProvider from "./RTLCacheProvider";
 import RTLCacheProvider from "./RTLCacheProvider";
 import session from "../app/Session";
 import session from "../app/Session";
 
 
@@ -29,20 +29,6 @@ initI18n();
 
 
 export const AccountContext = createContext(null);
 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 App = () => {
   const { i18n } = useTranslation();
   const { i18n } = useTranslation();
   const languageDir = i18n.dir();
   const languageDir = i18n.dir();
@@ -97,6 +83,7 @@ const App = () => {
 const updateTitle = (newNotificationsCount) => {
 const updateTitle = (newNotificationsCount) => {
   document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
   document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
   window.navigator.setAppBadge?.(newNotificationsCount);
   window.navigator.setAppBadge?.(newNotificationsCount);
+  updateFavicon(newNotificationsCount);
 };
 };
 
 
 const Layout = () => {
 const Layout = () => {